Compare commits

...

220 Commits

Author SHA1 Message Date
snipe 240196c3a1 Fixed logging 2026-05-04 11:46:34 +01:00
snipe 409ce69e32 Added some logging 2026-05-04 11:34:04 +01:00
snipe 0bdd3034d1 Pint 2026-05-04 11:00:34 +01:00
snipe f04a41408d Include custom fields in checkout emails (if they’re supposed to be included) 2026-05-04 11:00:27 +01:00
snipe 15effb1974 Formatting changes 2026-04-30 10:34:17 +01:00
snipe ccd60eb6d0 Added signed in place admin info 2026-04-30 10:34:06 +01:00
snipe 2f54f5b051 Fixed docblock 2026-04-30 10:13:48 +01:00
snipe 509584d1ff Make sure we’re logging action_source, IP, etc 2026-04-30 10:13:38 +01:00
snipe 95ac268046 Small cleanups 2026-04-30 09:54:08 +01:00
snipe f4b9736862 Fixed tests 2026-04-30 09:53:52 +01:00
snipe a0bf7a018c Only allow admins/super admins to delete or resend from unaccepted items 2026-04-29 17:18:05 +01:00
snipe ac4975e1d1 Added tests 2026-04-29 17:17:41 +01:00
snipe c7c3b04c5f Deleted old blade 2026-04-29 16:59:34 +01:00
snipe 88f87db4fd Renamed blade 2026-04-29 16:59:16 +01:00
snipe ad0199f662 Updated test strings, added form permission check 2026-04-29 16:47:15 +01:00
snipe 75a276d9fa Moved checkbox 2026-04-29 16:06:44 +01:00
snipe ead0047629 Migration to add qty to consumables_users table 2026-04-29 15:22:03 +01:00
snipe 46f6766bb4 Added qty to signature 2026-04-29 15:21:48 +01:00
snipe 8a470d3ef9 Added method for acceptance on consumable 2026-04-29 15:21:28 +01:00
snipe 5506245959 Fixed breadcrumbs 2026-04-29 15:11:51 +01:00
snipe 0fc175581a Check for app.always_send_email 2026-04-29 15:11:20 +01:00
snipe cd9005f82b RMB for consumables 2026-04-29 15:11:05 +01:00
snipe 24237d4259 Added always_send_email env var 2026-04-29 14:58:05 +01:00
snipe b2d707aaab Pint 2026-04-28 16:25:55 +01:00
snipe a432f23692 Add icons to show visibility 2026-04-28 16:25:48 +01:00
snipe a63b9ec627 Pint 2026-04-28 13:13:39 +01:00
snipe a35820d612 Added assigning user and indicator that it was signed in place 2026-04-28 13:13:31 +01:00
snipe ecf8ce3ec1 Pint 2026-04-28 12:54:34 +01:00
snipe 1908beb671 Check for custom fields with show_in_email = 1, field_encrypted = 0 2026-04-28 12:54:23 +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 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
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 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 4eba97d388 Added Armenian as a possible language 2026-04-22 15:01:11 +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 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 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 1c5d81cb04 Parse through carbon to make suyre the dates match properly 2026-04-20 15:54:45 +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 5bc273686e Use support_url in manufacturer blade component 2026-04-20 14:18:58 +01: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 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 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 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 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 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
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
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 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 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 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 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 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 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 51ea1327cf Merge pull request #18861 from grokability/log-authed-user-header
Log authed user ID header
2026-04-09 20:16:12 +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
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 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
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
1280 changed files with 18080 additions and 6907 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
+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!
@@ -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');
}
}
+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')
@@ -6,9 +6,16 @@ use App\Events\CheckoutAccepted;
use App\Events\CheckoutDeclined;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\AcceptSignatureRequest;
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;
@@ -30,7 +37,8 @@ class AcceptanceController extends Controller
*/
public function index(): View
{
$acceptances = CheckoutAcceptance::forUser(auth()->user())->pending()->get();
$user = auth()->user();
$acceptances = CheckoutAcceptance::forUser($user)->pending()->get();
return view('account/accept.index', compact('acceptances'));
}
@@ -40,27 +48,43 @@ 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) {
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (is_null($acceptance)) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('general.generic_model_not_found', ['model' => trans('general.accept_eula')]));
}
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'));
}
if (! Company::isCurrentUserHasAccess($acceptance->checkoutable)) {
return redirect()->route('account.accept')->with('error', trans('general.error_user_company'));
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
}
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'));
}
/**
@@ -68,22 +92,32 @@ class AcceptanceController extends Controller
*
* @param int $id
*/
public function store(Request $request, $id): RedirectResponse
public function store(AcceptSignatureRequest $request, CheckoutAcceptance $acceptance): RedirectResponse
{
$currentUser = auth()->user();
if (! $acceptance = CheckoutAcceptance::find($id)) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
if (! $currentUser instanceof User) {
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
}
$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 +146,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 +178,34 @@ 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)));
}
if ($isSignInPlaceAdminFlow && (! $acceptance->signed_in_place || (int) $acceptance->signed_in_place_admin !== (int) $currentUser->id)) {
$acceptance->forceFill([
'signed_in_place' => true,
'signed_in_place_admin' => $currentUser->id,
])->save();
}
// Determine signed_in_place and admin for PDF/email output
$signedInPlace = $isSignInPlaceAdminFlow ? true : (bool) $acceptance->signed_in_place;
$signedInPlaceAdmin = null;
if ($isSignInPlaceAdminFlow) {
$signedInPlaceAdmin = [
'name' => $currentUser->display_name,
'username' => $currentUser->username,
'email' => $currentUser->email,
];
} elseif ($acceptance->signed_in_place && $acceptance->signed_in_place_admin) {
$admin = User::find($acceptance->signed_in_place_admin);
if ($admin) {
$signedInPlaceAdmin = [
'name' => $admin->display_name,
'username' => $admin->username,
'email' => $admin->email,
];
}
}
// Get the data array ready for the notifications and PDF generation
@@ -148,35 +220,60 @@ 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,
'signed_in_place' => $signedInPlace,
];
if ($signedInPlaceAdmin) {
$data['signed_in_place_admin'] = $signedInPlaceAdmin;
}
if ($request->input('asset_acceptance') == 'accepted') {
// Add custom fields for asset (show_in_email = 1, field_encrypted = 0)
$customFields = [];
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
$fields = $item->model->fieldset->fields->where('show_in_email', true)->where('field_encrypted', false);
foreach ($fields as $field) {
$label = $field->name;
$dbColumn = $field->db_column;
$value = $item->$dbColumn;
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $label,
'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';
// Generate the PDF content
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
Storage::put('private_uploads/eula-pdfs/'.$pdf_filename, $pdf_content);
// Log the acceptance
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
$accept_qty = $request->input('accept_qty', $acceptance->qty ?? 1);
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'), $accept_qty);
$alwaysSendAcceptanceCopy = (bool) (config('app.always_send_email') || config('app.always_send_eula'));
// Send the PDF to the signing user
if (($request->input('send_copy') == '1') && ($assigned_user->email != '')) {
if (($alwaysSendAcceptanceCopy || ($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 +312,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 +320,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()) {
@@ -405,11 +405,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);
}
+46 -13
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;
@@ -953,6 +962,11 @@ 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.'));
}
@@ -1109,11 +1123,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 +1161,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 +1205,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 +1238,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);
}
@@ -1460,11 +1493,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;
@@ -391,11 +391,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);
}
@@ -361,11 +361,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);
}
@@ -282,11 +282,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);
}
@@ -260,11 +260,11 @@ class MaintenancesController extends Controller
$this->authorize('view', Asset::class);
$asset = $maintenance->asset;
$this->authorize('history', $asset);
$history = $maintenance->getHistory($request);
$total = $maintenance->getHistory($request)->count();
$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);
}
@@ -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');
@@ -164,4 +185,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'));
}
@@ -360,8 +360,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?
@@ -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;
@@ -24,33 +25,27 @@ class ConsumableCheckoutController extends Controller
*
* @param int $id
*/
public function create($id): View|RedirectResponse
public function create(Consumable $consumable): View|RedirectResponse
{
if ($consumable = Consumable::find($id)) {
$this->authorize('checkout', $consumable);
$this->authorize('checkout', $consumable);
// Make sure the category is valid
if ($consumable->category) {
// Make sure the category is valid
if ($consumable->category) {
// Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0) {
return redirect()->route('consumables.index')
->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => 1, 'remaining' => $consumable->numRemaining()]));
}
// Return the checkout view
return view('consumables/checkout', compact('consumable'));
// Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0) {
return redirect()->route('consumables.index')
->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => 1, 'remaining' => $consumable->numRemaining()]));
}
// Invalid category
return redirect()->route('consumables.edit', ['consumable' => $consumable->id])
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]));
// Return the checkout view
return view('consumables/checkout', compact('consumable'));
}
// Not found
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.does_not_exist'));
// Invalid category
return redirect()->route('consumables.edit', ['consumable' => $consumable->id])
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]));
}
@@ -67,12 +62,8 @@ class ConsumableCheckoutController extends Controller
*
* @throws AuthorizationException
*/
public function store(Request $request, $consumableId)
public function store(Request $request, Consumable $consumable)
{
if (is_null($consumable = Consumable::with('users')->find($consumableId))) {
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.not_found'));
}
$this->authorize('checkout', $consumable);
// If the quantity is not present in the request or is not a positive integer, set it to 1
@@ -98,14 +89,14 @@ class ConsumableCheckoutController extends Controller
// Update the consumable data
$consumable->assigned_to = e($request->input('assigned_to'));
for ($i = 0; $i < $quantity; $i++) {
$consumable->users()->attach($consumable->id, [
'consumable_id' => $consumable->id,
'created_by' => $admin_user->id,
'assigned_to' => e($request->input('assigned_to')),
'note' => $request->input('note'),
]);
}
// Attach the consumable to the user ONCE with the correct qty and note
$consumable->users()->attach($consumable->id, [
'consumable_id' => $consumable->id,
'created_by' => $admin_user->id,
'assigned_to' => $assigned_to,
'note' => $request->input('note'),
'qty' => $quantity,
]);
$consumable->checkout_qty = $quantity;
@@ -116,12 +107,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')
@@ -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;
}
@@ -251,6 +251,7 @@ class ProfileController extends Controller
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
{
$filename = basename((string) $filename);
$logentry = Actionlog::where('filename', $filename)->first();
+26 -11
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;
@@ -917,7 +918,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 +1071,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;
}
}
@@ -1211,7 +1218,7 @@ class ReportsController extends Controller
->filter(fn ($unaccepted) => $unaccepted->checkoutable)
->map(fn ($unaccepted) => Checkoutable::fromAcceptance($unaccepted));
return view('reports/unaccepted_assets', compact('itemsForReport', 'showDeleted'));
return view('reports/unaccepted_items', compact('itemsForReport', 'showDeleted'));
}
/**
@@ -1223,6 +1230,10 @@ class ReportsController extends Controller
*/
public function sentAssetAcceptanceReminder(Request $request): RedirectResponse
{
$user = auth()->user();
if (! ($user?->isAdmin() || $user?->isSuperUser())) {
abort(403);
}
$this->authorize('reports.view');
$id = $request->input('acceptance_id');
$query = CheckoutAcceptance::query()
@@ -1244,7 +1255,7 @@ class ReportsController extends Controller
Log::debug('No pending acceptances');
// Redirect to the unaccepted items report page with error
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
}
$item = $acceptance->checkoutable;
$assignee = $acceptance->assignedTo ?? $item->assignedTo ?? null;
@@ -1256,7 +1267,7 @@ class ReportsController extends Controller
if (is_null($acceptance->created_at)) {
Log::debug('No acceptance created_at');
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
} else {
if ($item instanceof LicenseSeat) {
$logItem_res = $item->license->checkouts()->with('adminuser')->where('created_at', '=', $acceptance->created_at)->get();
@@ -1266,18 +1277,18 @@ class ReportsController extends Controller
if ($logItem_res->isEmpty()) {
Log::debug('Acceptance date mismatch');
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
}
$logItem = $logItem_res[0];
}
if (is_null($email) || $email === '') {
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.no_email'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.no_email'));
}
$mailable = $this->getCheckoutMailType($acceptance, $logItem);
Mail::to($email)->send($mailable->locale($locale));
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.reminder_sent'));
return redirect()->route('reports/unaccepted_items')->with('success', trans('admin/reports/general.reminder_sent'));
}
private function getCheckoutMailType(CheckoutAcceptance $acceptance, $logItem): Mailable
@@ -1310,17 +1321,21 @@ class ReportsController extends Controller
*/
public function deleteAssetAcceptance($acceptanceId = null): RedirectResponse
{
$user = auth()->user();
if (! ($user?->isAdmin() || $user?->isSuperUser())) {
abort(403);
}
$this->authorize('reports.view');
if (! $acceptance = CheckoutAcceptance::pending()->find($acceptanceId)) {
// Redirect to the unaccepted assets report page with error
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
}
if ($acceptance->delete()) {
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.acceptance_deleted'));
return redirect()->route('reports/unaccepted_items')->with('success', trans('admin/reports/general.acceptance_deleted'));
} else {
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.deletion_failed'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.deletion_failed'));
}
}
+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');
}
}
@@ -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,6 +24,7 @@ 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 Symfony\Component\HttpFoundation\StreamedResponse;
@@ -436,6 +439,7 @@ class UsersController extends Controller
'accessories',
'licenses',
'userloc',
'groups',
])
->withTrashed()
->find($user->id);
@@ -446,6 +450,7 @@ class UsersController extends Controller
return view('users/view', [
'user' => $user,
'settings' => Setting::getSettings(),
'effectivePermissionsBySection' => $user->getEffectivePermissionsBySection(),
]);
}
@@ -700,6 +705,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,
],
@@ -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;
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class AcceptSignatureRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$acceptance = $this->route('acceptance');
$user = Auth::user();
if (! $acceptance || ! $user) {
return false;
}
if (is_string($acceptance)) {
$acceptance = CheckoutAcceptance::find($acceptance);
if (! $acceptance) {
return false;
}
}
if (! $user instanceof User) {
return false;
}
// Only allow if the user is the assigned user or sign-in-place admin
$assignedToId = $acceptance->assigned_to_id ?? null;
$isSignInPlaceAdmin = session('sign_in_place_acceptance_id') === $acceptance->id && $user->can('checkout', $acceptance->checkoutable);
return $user->id === $assignedToId || $isSignInPlaceAdmin;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
// ...existing validation rules...
];
}
protected function failedAuthorization()
{
$user = Auth::user();
$acceptance = $this->route('acceptance');
// If user is logged in and acceptance exists, treat as business logic error
if ($user && $acceptance) {
$redirectResponse = redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
throw new ValidationException($this->getValidatorInstance(), $redirectResponse);
}
// Otherwise, use default 403
parent::failedAuthorization();
}
}
+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) {
+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'),
+89 -39
View File
@@ -40,6 +40,7 @@ use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Osama\LaravelTeamsNotification\TeamsNotification;
class CheckoutableListener
{
private array $skipNotificationsFor = [
@@ -77,9 +78,14 @@ class CheckoutableListener
$acceptance = $this->getCheckoutAcceptance($event);
$shouldSendEmailToUser = $this->shouldSendCheckoutEmailToUser($event->checkoutable);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance, $event->checkoutable);
$shouldSendWebhookNotification = $this->shouldSendWebhookNotification();
if ($this->shouldSkipInitialAcceptanceEmail($event, $acceptance)) {
$shouldSendEmailToUser = false;
$shouldSendEmailToAlertAddress = false;
}
if (! $shouldSendEmailToUser && ! $shouldSendEmailToAlertAddress && ! $shouldSendWebhookNotification) {
return;
}
@@ -169,7 +175,7 @@ class CheckoutableListener
}
$shouldSendEmailToUser = $this->checkoutableCategoryShouldSendEmail($event->checkoutable);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress();
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress(null, $event->checkoutable);
$shouldSendWebhookNotification = $this->shouldSendWebhookNotification();
if (! $shouldSendEmailToUser && ! $shouldSendEmailToAlertAddress && ! $shouldSendWebhookNotification) {
return;
@@ -192,33 +198,39 @@ class CheckoutableListener
}
$mailable = $this->getCheckinMailType($event);
$notifiable = $this->getNotifiableUser($event);
$notifiableHasEmail = $notifiable instanceof User && $notifiable->email;
if (! $mailable) {
Log::debug('No checkin mail type available for checkoutable class: '.get_class($event->checkoutable));
} else {
$notifiable = $this->getNotifiableUser($event);
$shouldSendEmailToUser = $shouldSendEmailToUser && $notifiableHasEmail;
$notifiableHasEmail = $notifiable instanceof User && $notifiable->email;
[$to, $cc] = $this->generateEmailRecipients($shouldSendEmailToUser, $shouldSendEmailToAlertAddress, $notifiable);
$shouldSendEmailToUser = $shouldSendEmailToUser && $notifiableHasEmail;
if (! empty($to)) {
try {
$toMail = (clone $mailable)->locale($notifiable->locale);
Mail::to(array_flatten($to))->send($toMail);
Log::info('Checkin Mail sent to checkin target');
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
[$to, $cc] = $this->generateEmailRecipients($shouldSendEmailToUser, $shouldSendEmailToAlertAddress, $notifiable);
if (! empty($to)) {
try {
$toMail = (clone $mailable)->locale($notifiable->locale);
Mail::to(array_flatten($to))->send($toMail);
Log::info('Checkin Mail sent to checkin target');
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
}
}
}
if (! empty($cc)) {
try {
$ccMail = (clone $mailable)->locale(Setting::getSettings()->locale);
Mail::cc(array_flatten($cc))->send($ccMail);
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
if (! empty($cc)) {
try {
$ccMail = (clone $mailable)->locale(Setting::getSettings()->locale);
Mail::cc(array_flatten($cc))->send($ccMail);
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
}
}
}
}
@@ -386,7 +398,14 @@ class CheckoutableListener
LicenseSeat::class => CheckinLicenseMail::class,
Component::class => CheckinComponentMail::class,
];
$mailable = $lookup[get_class($event->checkoutable)];
$checkoutableClass = get_class($event->checkoutable);
if (! array_key_exists($checkoutableClass, $lookup)) {
return null;
}
$mailable = $lookup[$checkoutableClass];
return new $mailable($event->checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note);
@@ -460,11 +479,16 @@ class CheckoutableListener
* 1. The asset requires acceptance
* 2. The item has a EULA
* 3. The item should send an email at check-in/check-out
* 4. The config('app.always_send_email') is true
*/
if (Context::get('action') === 'bulk_asset_checkout') {
return false;
}
if (config('app.always_send_email')) {
return true;
}
if ($checkoutable->requireAcceptance()) {
return true;
}
@@ -480,7 +504,16 @@ class CheckoutableListener
return false;
}
private function shouldSendEmailToAlertAddress($acceptance = null): bool
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, ?Model $checkoutable = null): bool
{
if (Context::get('action') === 'bulk_asset_checkout') {
return false;
@@ -492,22 +525,39 @@ class CheckoutableListener
return false;
}
if (is_null($acceptance) && ! $setting->admin_cc_always) {
$alertRecipients = $this->getFormattedAlertAddresses((bool) $setting->admin_cc_always);
if (empty($alertRecipients)) {
return false;
}
return (bool) $setting->admin_cc_email;
}
private function getFormattedAlertAddresses(): array
{
$alertAddresses = Setting::getSettings()->admin_cc_email;
if ($alertAddresses !== '') {
return array_filter(array_map('trim', explode(',', $alertAddresses)));
if (is_null($acceptance) && ! $setting->admin_cc_always) {
if (! $checkoutable || ! $this->checkoutableCategoryShouldSendEmail($checkoutable)) {
return false;
}
}
return [];
return true;
}
private function getFormattedAlertAddresses(bool $allowAlertEmailFallback = false): array
{
$setting = Setting::getSettings();
if (! $setting) {
return [];
}
$adminCcAddresses = $setting->admin_cc_email;
$fallbackAlertAddresses = $allowAlertEmailFallback ? $setting->alert_email : null;
$rawAddresses = $adminCcAddresses ?: $fallbackAlertAddresses;
if ($rawAddresses === null || $rawAddresses === '') {
return [];
}
return array_values(array_unique(array_filter(array_map('trim', explode(',', $rawAddresses)))));
}
private function generateEmailRecipients(
@@ -521,7 +571,7 @@ class CheckoutableListener
// if user && cc: to user, cc admin
if ($shouldSendEmailToUser && $shouldSendEmailToAlertAddress) {
$to[] = $notifiable;
$cc[] = $this->getFormattedAlertAddresses();
$cc[] = $this->getFormattedAlertAddresses(true);
}
// if user && no cc: to user
@@ -531,7 +581,7 @@ class CheckoutableListener
// if no user && cc: to admin
if (! $shouldSendEmailToUser && $shouldSendEmailToAlertAddress) {
$to[] = $this->getFormattedAlertAddresses();
$to[] = $this->getFormattedAlertAddresses(true);
}
return [$to, $cc];
+10 -3
View File
@@ -10,6 +10,7 @@ use App\Events\UserMerged;
use App\Models\Actionlog;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Log;
class LogListener
@@ -59,7 +60,10 @@ class LogListener
$logaction->action_type = 'accepted';
$logaction->action_date = $event->acceptance->accepted_at;
$logaction->quantity = $event->acceptance->qty ?? 1;
$logaction->created_by = auth()->user()->id;
$logaction->created_by = auth()->user()?->getAuthIdentifier();
$logaction->remote_ip = request()->ip();
$logaction->user_agent = request()->header('User-Agent');
$logaction->action_source = 'gui';
// TODO: log the actual license seat that was checked out
if ($event->acceptance->checkoutable instanceof LicenseSeat) {
@@ -79,7 +83,10 @@ class LogListener
$logaction->action_type = 'declined';
$logaction->action_date = $event->acceptance->declined_at;
$logaction->quantity = $event->acceptance->qty ?? 1;
$logaction->created_by = auth()->user()->id;
$logaction->created_by = auth()->user()?->getAuthIdentifier();
$logaction->remote_ip = request()->ip();
$logaction->user_agent = request()->header('User-Agent');
$logaction->action_source = 'gui';
// TODO: log the actual license seat that was checked out
if ($event->acceptance->checkoutable instanceof LicenseSeat) {
@@ -127,7 +134,7 @@ class LogListener
/**
* Register the listeners for the subscriber.
*
* @param Illuminate\Events\Dispatcher $events
* @param Dispatcher $events
*/
public function subscribe($events)
{
@@ -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';
}
}
+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');
+22
View File
@@ -9,6 +9,7 @@ use App\Presenters\ActionlogPresenter;
use App\Presenters\Presentable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
@@ -328,6 +329,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
*
+67 -7
View File
@@ -516,7 +516,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 +560,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 +761,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 +1264,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 +2166,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);
}
}
+66 -4
View File
@@ -23,6 +23,21 @@ class CheckoutAcceptance extends Model
'alert_on_response_id' => 'integer',
];
protected $fillable = [
'assigned_to_id',
'checkoutable_type',
'checkoutable_id',
'accepted_at',
'declined_at',
'note',
'signature_filename',
'stored_eula',
'stored_eula_file',
'qty',
'signed_in_place',
'signed_in_place_admin',
];
/**
* Get the mail recipient from the config
*
@@ -112,7 +127,7 @@ class CheckoutAcceptance extends Model
*/
public function isCheckedOutTo(User $user)
{
return $this->assignedTo?->is($user);
return $this->assigned_to_id === $user->id;
}
/**
@@ -121,20 +136,27 @@ class CheckoutAcceptance extends Model
* checkout_acceptances table or you'll get an error.
*
* @param string $signature_filename
* @param string|null $eula
* @param string|null $filename
* @param string|null $note
* @param int|null $qty
*/
public function accept($signature_filename, $eula = null, $filename = null, $note = null)
public function accept($signature_filename, $eula = null, $filename = null, $note = null, $qty = null)
{
$this->accepted_at = now();
$this->signature_filename = $signature_filename;
$this->stored_eula = $eula;
$this->stored_eula_file = $filename;
$this->note = $note;
if ($qty !== null) {
$this->qty = $qty;
}
$this->save();
/**
* Update state for the checked out item
*/
$this->checkoutable->acceptedCheckout($this->assignedTo, $signature_filename, $filename);
$this->checkoutable->acceptedCheckout($this->assignedTo, $qty, $note, $signature_filename, $filename);
}
/**
@@ -208,7 +230,7 @@ class CheckoutAcceptance extends Model
$pdf->SetAuthor($data['assigned_to']);
$pdf->SetTitle('Asset Acceptance: '.$data['item_tag']);
$pdf->SetSubject('Asset Acceptance: '.$data['item_tag']);
$pdf->SetKeywords('Snipe-IT, assets, acceptance, eula, tos');
$pdf->SetKeywords('Snipe-IT, assets, acceptance, eula, tos,'.$data['item_tag'] ?? null.', '.$data['item_name'] ?? null.', '.$data['assigned_to'] ?? null);
$pdf->SetFont('dejavusans', '', 8, '', true);
$pdf->SetPrintHeader(false);
$pdf->SetPrintFooter(false);
@@ -243,6 +265,17 @@ class CheckoutAcceptance extends Model
if ($data['item_serial'] != null) {
$pdf->writeHTML(trans('admin/hardware/form.serial').': '.e($data['item_serial']), true, 0, true, 0, '');
}
// Render custom fields if present
if (! empty($data['custom_fields']) && is_array($data['custom_fields'])) {
foreach ($data['custom_fields'] as $customField) {
$label = $customField['label'] ?? '';
$value = $customField['value'] ?? '';
if ($label !== '' && $value !== '') {
$pdf->writeHTML(e($label).': '.e($value), true, 0, true, 0, '');
}
}
}
if (($data['qty'] != null) && ($data['qty'] > 1)) {
$pdf->writeHTML(trans('general.qty').': '.e($data['qty']), true, 0, true, 0, '');
}
@@ -250,6 +283,35 @@ class CheckoutAcceptance extends Model
if ($data['email'] != null) {
$pdf->writeHTML(trans('general.email').': '.e($data['email']), true, 0, true, 0, '');
}
// Add assigning user if present
if (! empty($data['assigning_user'])) {
$assigningUser = $data['assigning_user'];
$assigningUserLine = trans('general.assigned_by').': '.e($assigningUser['name'] ?? $assigningUser['email'] ?? '');
if (! empty($assigningUser['employee_num'])) {
$assigningUserLine .= ' ('.e($assigningUser['employee_num']).')';
}
$pdf->writeHTML($assigningUserLine, true, 0, true, 0, '');
}
// Add signed in place row (always show)
$signedInPlace = ! empty($data['signed_in_place']) && filter_var($data['signed_in_place'], FILTER_VALIDATE_BOOLEAN);
$pdf->writeHTML(trans('general.signed_in_place').': '.($signedInPlace ? trans('general.yes') : trans('general.no')), true, 0, true, 0, '');
// If signed in place, show admin info
if ($signedInPlace && ! empty($data['signed_in_place_admin'])) {
$admin = $data['signed_in_place_admin'];
$adminName = $admin['name'] ?? '';
$adminUsername = $admin['username'] ?? '';
$adminEmail = $admin['email'] ?? '';
$adminDetails = $adminName;
if (! empty($adminUsername)) {
$adminDetails .= ' ('.$adminUsername.')';
}
if (! empty($adminEmail)) {
$adminDetails .= ' <'.$adminEmail.'>';
}
$adminLine = trans('general.signed_in_place_admin', ['admin' => $adminDetails]);
$pdf->writeHTML($adminLine, true, 0, true, 0, '');
}
$pdf->Ln();
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
+21
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
*
+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();
}
/**
+83
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;
}
@@ -491,4 +495,83 @@ class Consumable extends SnipeModel
{
return $query->leftJoin('users as users_sort', 'consumables.created_by', '=', 'users_sort.id')->select('consumables.*')->orderBy('users_sort.first_name', $order)->orderBy('users_sort.last_name', $order);
}
/**
* Handle logic after a consumable checkout is accepted by the user.
*
* @param string|null $signature
* @param string|null $filename
*/
public function acceptedCheckout(User $acceptedBy, ?int $qty = null, ?string $note = null, $signature = null, $filename = null): void
{
// Find the pending acceptance for this user and consumable
$acceptance = $acceptedBy->getAssignedItemsWithPendingAcceptance()
->where('item_id', $this->id)
->where('qty', $qty)
->where('item_type', self::class)
->whereNull('declined_at')
->sortByDesc('created_at')
->first();
if ($acceptance) {
if ($qty !== null) {
$acceptance->qty = $qty;
}
if ($note !== null) {
$acceptance->note = $note;
}
$acceptance->save();
}
// Attach the consumable to the user if not already attached
$pivot = $acceptedBy->consumables()->where('consumable_id', $this->id)->first();
if (! $pivot) {
$acceptedBy->consumables()->attach($this->id, [
'created_by' => $acceptance?->created_by ?? null,
]);
}
// Logging handled by event listener; do not log here to avoid duplicates.
}
/**
* Handle logic after a consumable checkout is declined by the user.
*
* @param string|null $signature
*/
public function declinedCheckout(User $declinedBy, $signature = null): void
{
// Find the pending acceptance for this user and consumable
$acceptance = $declinedBy->acceptances()
->where('item_id', $this->id)
->where('item_type', self::class)
->whereNull('accepted_at')
->latest('created_at')
->first();
$qty = $acceptance?->qty ?? 1;
$note = $acceptance?->note;
// Detach the consumable from the user (if present)
$declinedBy->consumables()->detach($this->id);
// Logging handled by event listener; do not log here to avoid duplicates.
}
/**
* Log an acceptance or decline action for this consumable.
*/
protected function logActionAcceptance(string $actionType, User $user, int $qty, ?string $note = null): void
{
$this->assetlog()->create([
'action_type' => $actionType,
'target_id' => $user->id,
'target_type' => User::class,
'item_id' => $this->id,
'item_type' => self::class,
'quantity' => $qty,
'note' => $note,
'created_by' => auth()->id() ?? $user->id,
]);
}
}
+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)
{
+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);
}
}
+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
*
+29 -3
View File
@@ -107,7 +107,7 @@ trait Loggable
break;
}
return $history;
return $history->forApiHistory();
}
@@ -180,7 +180,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));
foreach ($originalValues as $key => $value) {
@@ -279,7 +279,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 +303,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>
*
+23 -14
View File
@@ -456,23 +456,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.'%');
}
}
});
}
/**
+175
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;
@@ -207,13 +208,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 +304,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
*
@@ -697,6 +856,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
*
@@ -33,6 +33,9 @@ class AcceptanceItemAcceptedNotification extends Notification
$this->file = $params['file'] ?? null;
$this->qty = $params['qty'] ?? null;
$this->note = $params['note'] ?? null;
$this->signed_in_place = $params['signed_in_place'] ?? false;
$this->signed_in_place_admin = $params['signed_in_place_admin'] ?? null;
$this->custom_fields = $params['custom_fields'] ?? [];
}
@@ -76,6 +79,9 @@ class AcceptanceItemAcceptedNotification extends Notification
'assigned_to' => $this->assigned_to,
'company_name' => $this->company_name,
'qty' => $this->qty,
'signed_in_place' => $this->signed_in_place,
'signed_in_place_admin' => $this->signed_in_place_admin,
'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)
@@ -32,6 +32,7 @@ class AcceptanceItemDeclinedNotification extends Notification
$this->settings = Setting::getSettings();
$this->qty = $params['qty'] ?? null;
$this->admin = $params['admin'] ?? null;
$this->custom_fields = $params['custom_fields'] ?? [];
}
/**
@@ -74,6 +75,7 @@ class AcceptanceItemDeclinedNotification extends Notification
'company_name' => $this->company_name,
'qty' => $this->qty,
'admin' => $this->admin,
'custom_fields' => $this->custom_fields,
'user' => $this->assigned_to,
'intro_text' => trans('mail.acceptance_declined_greeting', ['user' => $this->assigned_to]),
])
+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,
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Presenters;
use App\Models\CustomField;
/**
* Class CustomFieldPresenter
* Handles presentation logic for CustomField, including visibility icons.
*/
final class CustomFieldPresenter extends Presenter
{
private CustomField $field;
public function __construct(CustomField $field)
{
$this->field = $field;
}
/**
* Returns an array of icon HTML for where the field is visible.
*
* @return string[] Array of HTML icon strings
*/
public function visibilityIconsArray(): array
{
$icons = [];
if ($this->field->display_checkout) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_checkout')).'" data-tooltip="true"><i class="fa-solid fa-rotate-left text-muted"></i></span>';
}
if ($this->field->display_checkin) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_checkin')).'" data-tooltip="true"><i class="fa-solid fa-rotate-right text-muted"></i></span>';
}
if ($this->field->display_audit) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_audit')).'" data-tooltip="true"><i class="fas fa-clipboard-check text-muted"></i></span>';
}
if ($this->field->display_in_user_view) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_in_user_view_table')).'" data-tooltip="true"><i class="fas fa-user text-muted"></i></span>';
}
if ($this->field->show_in_listview) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.show_in_listview_short')).'" data-tooltip="true"><i class="fas fa-list text-muted"></i></span>';
}
if ($this->field->show_in_email) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.show_in_email_short')).'" data-tooltip="true"><i class="fas fa-envelope text-muted"></i></span>';
}
if ($this->field->show_in_requestable_list) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.show_in_requestable_list_short')).'" data-tooltip="true"><i class="fa-solid fa-bell-concierge text-muted"></i></span>';
}
return $icons;
}
/**
* Returns the icons as a single HTML string (for backward compatibility)
*/
public function visibilityIcons(): string
{
return implode(' ', $this->visibilityIconsArray());
}
}
+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
@@ -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');
}
+32
View File
@@ -233,6 +233,21 @@ return [
'allow_iframing' => env('ALLOW_IFRAMING', false),
/*
|--------------------------------------------------------------------------
| LOG AUTHED USER HEADER
|--------------------------------------------------------------------------
|
| This is an additional header that can be enabled to include the authenticated user's ID
| in the response headers of each request. This can be useful for debugging and auditing purposes,
| but it may also expose sensitive information if not used carefully.
| It should normally be set to false unless you have a specific need for it and
| understand the security implications.
|
*/
'authorized_user_header' => env('INCLUDE_AUTHED_USER_HEADER', false),
/*
|--------------------------------------------------------------------------
| ENABLE HTTP Strict Transport Security (HSTS)
@@ -522,4 +537,21 @@ return [
'max_unpaginated_records' => env('MAX_UNPAGINATED', '5000'),
/*
|--------------------------------------------------------------------------
| Always send emails on acceptance/EULA
|--------------------------------------------------------------------------
| This setting allows you to bypass the "email me a copy" checkbox on EULA/item acceptance,
| and forces Snipe-IT to always send email to the accepting user if they have an email address.
*/
'always_send_email' => env('ALWAYS_SEND_EMAIL', false),
/*
|--------------------------------------------------------------------------
| Always Send EULA
|--------------------------------------------------------------------------
| If true, the EULA will always be sent and the checkbox will be hidden.
*/
'always_send_eula' => env('ALWAYS_SEND_EULA', false),
];
+7 -2
View File
@@ -97,8 +97,13 @@ $config = [
],
'backup' => [
'driver' => env('PRIVATE_FILESYSTEM_DISK', 'local'),
'root' => storage_path('app'),
'driver' => env('BACKUP_FILESYSTEM_DRIVER', 'local'),
'key' => env('PRIVATE_AWS_ACCESS_KEY_ID'),
'secret' => env('PRIVATE_AWS_SECRET_ACCESS_KEY'),
'region' => env('PRIVATE_AWS_DEFAULT_REGION'),
'bucket' => env('PRIVATE_AWS_BUCKET'),
'root' => env('BACKUP_FILESYSTEM_ROOT', storage_path('app')),
'visibility' => 'private',
],
],
@@ -22,6 +22,14 @@ class AddEolDateOnAssetsTable extends Migration
$table->date('asset_eol_date')->after('purchase_date')->nullable()->default(null);
}
// This is a back in time migration to fix restores from very old versions of Snipe-IT where
// companies were not soft-deletable.
Schema::table('companies', function (Blueprint $table) {
if (! Schema::hasColumn('companies', 'deleted_at')) {
$table->softDeletes();
}
});
// This is a temporary shim so we don't have to modify the asset observer for migrations where
// there is a large version difference. (See the AssetObserver notes). This column gets created
// later in 2023_07_13_052204_denormalized_eol_and_add_column_for_explicit_date_to_assets.php
@@ -18,6 +18,13 @@ class DenormalizedEolAndAddColumnForExplicitDateToAssets extends Migration
*/
public function up()
{
Schema::table('companies', function (Blueprint $table) {
if (! Schema::hasColumn('companies', 'deleted_at')) {
$table->softDeletes();
}
});
Schema::table('assets', function (Blueprint $table) {
if (! Schema::hasColumn('assets', 'eol_explicit')) {
$table->boolean('eol_explicit')->default(false)->after('asset_eol_date');
@@ -54,6 +61,7 @@ class DenormalizedEolAndAddColumnForExplicitDateToAssets extends Migration
->update([
'asset_eol_date' => $this->eolUpdateExpression(),
]);
}
/**
@@ -11,8 +11,11 @@ return new class extends Migration
*/
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->softDeletes();
if (! Schema::hasColumn('companies', 'deleted_at')) {
$table->softDeletes();
}
});
}
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('consumables_users', function (Blueprint $table) {
$table->unsignedInteger('qty')->nullable()->default(1)->after('created_by');
});
}
public function down(): void
{
Schema::table('consumables_users', function (Blueprint $table) {
$table->dropColumn('qty');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('checkout_acceptances', function (Blueprint $table) {
$table->boolean('signed_in_place')->default(false)->after('declined_at');
$table->unsignedBigInteger('signed_in_place_admin')->nullable()->after('signed_in_place');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('checkout_acceptances', function (Blueprint $table) {
$table->dropColumn(['signed_in_place', 'signed_in_place_admin']);
});
}
};
+1
View File
@@ -124,6 +124,7 @@ fi
php artisan migrate --force
php artisan config:clear
php artisan config:cache
php artisan view:clear
# we do this after the artisan commands to ensure that if the laravel
# log got created by root, we set the permissions back
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"/js/dist/all.js": "/js/dist/all.js?id=aaa47313ae3ee7925789f7757e7a2c8b",
"/js/dist/all.js": "/js/dist/all.js?id=6ed2062a051f86b7fed3b1f894749219",
"/css/build/overrides.css": "/css/build/overrides.css?id=9bfab28a94932d45568ad50f3c6c5e2c",
"/css/build/app.css": "/css/build/app.css?id=4b2abd7fa3560ada549e9d08bd836aa8",
"/css/build/AdminLTE.css": "/css/build/AdminLTE.css?id=bdf169bc2141f453390614c138cdce95",
+29 -12
View File
@@ -149,8 +149,8 @@ $(function () {
// deleteForm is the ID of the modal form itself
$('#deleteForm').attr('action', href);
$dataConfirmModal.find('.modal-header-icon').addClass(headericon);
$dataConfirmModal.find('.modal-title').text(title).prepend('<i class="fa ' + headericon + '"></i> ');
$dataConfirmModal.find('.modal-body').text(message);
$dataConfirmModal.find('.modal-title').text('').text(title).prepend('<i class="fa ' + headericon + '"></i> ');
$dataConfirmModal.find('.modal-body').text('').text(message);
$dataConfirmModal.attr('action', href);
// Fire the modal
@@ -416,7 +416,13 @@ $(function () {
// This handles the radio button selectors for the checkout-to-foo options
// on asset checkout and also on asset edit
$(function() {
$('input[name=checkout_to_type]').on("change",function () {
var checkoutToTypeInputs = $('input[name=checkout_to_type]');
if (!checkoutToTypeInputs.length) {
return;
}
function syncCheckoutToTypeUi(resetSelections) {
var assignto_type = $('input[name=checkout_to_type]:checked').val();
var userid = $('#assigned_user option:selected').val();
@@ -427,9 +433,10 @@ $(function () {
$('#assigned_location').hide();
$('.notification-callout').fadeOut();
$('[name="assigned_location"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
if (resetSelections) {
$('[name="assigned_location"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
}
} else if (assignto_type == 'location') {
$('#current_assets_box').fadeOut();
$('#assigned_asset').hide();
@@ -437,10 +444,11 @@ $(function () {
$('#assigned_location').show();
$('.notification-callout').fadeOut();
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
} else {
if (resetSelections) {
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
}
} else {
$('#assigned_asset').hide();
$('#assigned_user').show();
$('#assigned_location').hide();
@@ -449,10 +457,19 @@ $(function () {
}
$('.notification-callout').fadeIn();
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_location"]').val('').trigger('change.select2');
if (resetSelections) {
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_location"]').val('').trigger('change.select2');
}
}
}
checkoutToTypeInputs.on('change', function () {
syncCheckoutToTypeUi(true);
});
// Apply the current radio selection on initial render.
syncCheckoutToTypeUi(false);
});
@@ -9,7 +9,7 @@ return [
'edit' => 'crwdns1606:0crwdne1606:0',
'eula_text' => 'crwdns1210:0crwdne1210:0',
'eula_text_help' => 'crwdns1211:0crwdne1211:0',
'require_acceptance' => 'crwdns1212:0crwdne1212:0',
'require_acceptance' => 'crwdns14698:0crwdne14698:0',
'no_default_eula' => 'crwdns1213:0crwdne1213:0',
'total' => 'crwdns1215:0crwdne1215:0',
'remaining' => 'crwdns1216:0crwdne1216:0',
@@ -17,6 +17,7 @@ return [
'name' => 'crwdns1835:0crwdne1835:0',
'require_acceptance' => 'crwdns1243:0crwdne1243:0',
'required_acceptance' => 'crwdns1244:0crwdne1244:0',
'global_signature_required_notice' => 'crwdns14708:0crwdne14708:0',
'required_eula' => 'crwdns1245:0crwdne1245:0',
'no_default_eula' => 'crwdns1246:0crwdne1246:0',
'update' => 'crwdns639:0crwdne639:0',
@@ -5,7 +5,7 @@ return [
'manage' => 'crwdns6501:0crwdne6501:0',
'field' => 'crwdns1487:0crwdne1487:0',
'about_fieldsets_title' => 'crwdns1488:0crwdne1488:0',
'about_fieldsets_text' => 'crwdns14508:0crwdne14508:0',
'about_fieldsets_text' => 'crwdns14566:0crwdne14566:0',
'custom_format' => 'crwdns6505:0crwdne6505:0',
'encrypt_field' => 'crwdns1792:0crwdne1792:0',
'encrypt_field_help' => 'crwdns1683:0crwdne1683:0',
@@ -7,5 +7,6 @@ return [
'invalid_return_value' => 'crwdns11719:0crwdne11719:0',
'does_not_exist' => 'crwdns11721:0crwdne11721:0',
'use_new_label_engine_for_api' => 'crwdns14722:0crwdne14722:0',
'label_not_created' => 'crwdns14724:0crwdne14724:0',
];
@@ -8,7 +8,6 @@ return [
'assoc_child_loc' => 'crwdns1405:0crwdne1405:0',
'assigned_assets' => 'crwdns11179:0crwdne11179:0',
'current_location' => 'crwdns11181:0crwdne11181:0',
'open_map' => 'crwdns12696:0crwdne12696:0',
'deleted_warning' => 'crwdns13790:0crwdne13790:0',
'create' => [
@@ -2,13 +2,13 @@
return [
'select_type' => 'crwdns13584:0crwdne13584:0',
'asset_maintenance_type' => 'crwdns13586:0crwdne13586:0',
'asset_maintenance_type' => 'crwdns14588:0crwdne14588:0',
'title' => 'crwdns13588:0crwdne13588:0',
'start_date' => 'crwdns13590:0crwdne13590:0',
'completion_date' => 'crwdns13592:0crwdne13592:0',
'cost' => 'crwdns13594:0crwdne13594:0',
'is_warranty' => 'crwdns13596:0crwdne13596:0',
'asset_maintenance_time' => 'crwdns13598:0crwdne13598:0',
'asset_maintenance_time' => 'crwdns14586:0crwdne14586:0',
'notes' => 'crwdns13600:0crwdne13600:0',
'update' => 'crwdns13602:0crwdne13602:0',
'create' => 'crwdns13604:0crwdne13604:0',
@@ -309,7 +309,7 @@ return [
'two_factor_enabled_edit_not_allowed' => 'crwdns1818:0crwdne1818:0',
'two_factor_enrollment_text' => 'crwdns1791:0crwdne1791:0',
'require_accept_signature' => 'crwdns1819:0crwdne1819:0',
'require_accept_signature_help_text' => 'crwdns1820:0crwdne1820:0',
'require_accept_signature_help_text' => 'crwdns14704:0crwdne14704:0',
'require_checkinout_notes' => 'crwdns12794:0crwdne12794:0',
'require_checkinout_notes_help_text' => 'crwdns12796:0crwdne12796:0',
'left' => 'crwdns1597:0crwdne1597:0',
@@ -324,15 +324,30 @@ return [
'username_format_help' => 'crwdns5918:0crwdne5918:0',
'oauth_title' => 'crwdns6403:0crwdne6403:0',
'oauth_clients' => 'crwdns12246:0crwdne12246:0',
'oauth' => 'crwdns6405:0crwdne6405:0',
'oauth_help' => 'crwdns6407:0crwdne6407:0',
'oauth' => 'crwdns14658:0crwdne14658:0',
'oauth_help' => 'crwdns14660:0crwdne14660:0',
'oauth_no_clients' => 'crwdns12248:0crwdne12248:0',
'oauth_no_applications' => 'crwdns14662:0crwdne14662:0',
'oauth_secret' => 'crwdns12250:0crwdne12250:0',
'oauth_authorized_apps' => 'crwdns12252:0crwdne12252:0',
'oauth_redirect_url' => 'crwdns12254:0crwdne12254:0',
'oauth_name_help' => 'crwdns12256:0crwdne12256:0',
'oauth_scopes' => 'crwdns12258:0crwdne12258:0',
'oauth_client_type' => 'crwdns14664:0crwdne14664:0',
'oauth_client_type_oauth' => 'crwdns14666:0crwdne14666:0',
'oauth_client_type_personal_access' => 'crwdns14668:0crwdne14668:0',
'oauth_client_type_password_grant' => 'crwdns14670:0crwdne14670:0',
'oauth_associated_token_count' => 'crwdns14672:0crwdne14672:0',
'oauth_callback_url' => 'crwdns12260:0crwdne12260:0',
'oauth_personal_access_tokens' => 'crwdns14674:0crwdne14674:0',
'oauth_personal_access_tokens_none' => 'crwdns14676:0crwdne14676:0',
'oauth_client' => 'crwdns14678:0crwdne14678:0',
'oauth_deleted_user' => 'crwdns14680:0crwdne14680:0',
'oauth_token_status_revoked' => 'crwdns14682:0crwdne14682:0',
'oauth_token_status_expired' => 'crwdns14684:0crwdne14684:0',
'oauth_token_status_active' => 'crwdns14686:0crwdne14686:0',
'oauth_revoke' => 'crwdns14688:0crwdne14688:0',
'oauth_unrevoke' => 'crwdns14690:0crwdne14690:0',
'create_client' => 'crwdns12262:0crwdne12262:0',
'no_scopes' => 'crwdns12264:0crwdne12264:0',
'asset_tag_title' => 'crwdns6409:0crwdne6409:0',
@@ -346,6 +361,7 @@ return [
'general_title' => 'crwdns6425:0crwdne6425:0',
'mail_test' => 'crwdns6427:0crwdne6427:0',
'mail_test_help' => 'crwdns6429:0crwdne6429:0',
'mail_test_no_email' => 'crwdns14696:0crwdne14696:0',
'filter_by_keyword' => 'crwdns6431:0crwdne6431:0',
'security' => 'crwdns6433:0crwdne6433:0',
'security_title' => 'crwdns6435:0crwdne6435:0',
@@ -525,6 +541,7 @@ return [
'purge' => 'crwdns12854:0crwdne12854:0',
'security' => 'crwdns12856:0crwdne12856:0',
'notifications' => 'crwdns13284:0crwdne13284:0',
'oauth' => 'crwdns14692:0crwdne14692:0',
],
];
@@ -56,4 +56,21 @@ return [
'not_saved' => 'crwdns13053:0crwdne13053:0',
'mismatch' => 'crwdns13055:0crwdne13055:0',
],
'oauth' => [
'token_revoked' => 'crwdns14624:0crwdne14624:0',
'token_unrevoked' => 'crwdns14626:0crwdne14626:0',
'token_not_found' => 'crwdns14628:0crwdne14628:0',
'token_revoke_error' => 'crwdns14630:0crwdne14630:0',
'token_unrevoke_error' => 'crwdns14632:0crwdne14632:0',
'client_created' => 'crwdns14634:0crwdne14634:0',
'client_updated' => 'crwdns14636:0crwdne14636:0',
'client_deleted' => 'crwdns14638:0crwdne14638:0',
'client_revoked' => 'crwdns14640:0crwdne14640:0',
'client_unrevoked' => 'crwdns14642:0crwdne14642:0',
'client_not_found' => 'crwdns14644:0crwdne14644:0',
'token_deleted' => 'crwdns14646:0crwdne14646:0',
'client_delete_denied' => 'crwdns14648:0crwdne14648:0',
'client_edit_denied' => 'crwdns14650:0crwdne14650:0',
'token_delete_denied' => 'crwdns14652:0crwdne14652:0',
],
];
@@ -14,6 +14,8 @@ return [
'filetype_info' => 'crwdns1391:0crwdne1391:0',
'history_user' => 'crwdns1129:0crwdne1129:0',
'info' => 'crwdns1848:0crwdne1848:0',
'send_acceptance_reminder' => 'crwdns14710:0crwdne14710:0',
'unaccepted_items' => 'crwdns14712:0crwdne14712:0',
'restore_user' => 'crwdns1912:0crwdne1912:0',
'last_login' => 'crwdns1130:0crwdne1130:0',
'ldap_config_text' => 'crwdns14248:0crwdne14248:0',
+4 -1
View File
@@ -15,6 +15,7 @@ return [
'user_deleted_warning' => 'crwdns1133:0crwdne1133:0',
'ldap_not_configured' => 'crwdns1412:0crwdne1412:0',
'password_resets_sent' => 'crwdns5922:0crwdne5922:0',
'not_activated' => 'crwdns14716:0crwdne14716:0',
'password_reset_sent' => 'crwdns6087:0crwdne6087:0',
'user_has_no_email' => 'crwdns10536:0crwdne10536:0',
'log_record_not_found' => 'crwdns11844:0crwdne11844:0',
@@ -30,6 +31,7 @@ return [
'unsuspend' => 'crwdns798:0crwdne798:0',
'restored' => 'crwdns799:0crwdne799:0',
'import' => 'crwdns1194:0crwdne1194:0',
'acceptance_reminder_sent' => 'crwdns14718:0crwdne14718:0',
],
'error' => [
@@ -44,7 +46,7 @@ return [
'delete_has_users_var' => 'crwdns12242:0crwdne12242:0',
'unsuspend' => 'crwdns803:0crwdne803:0',
'import' => 'crwdns1195:0crwdne1195:0',
'asset_already_accepted' => 'crwdns1267:0crwdne1267:0',
'asset_already_accepted' => 'crwdns14706:0crwdne14706:0',
'accept_or_decline' => 'crwdns1346:0crwdne1346:0',
'cannot_delete_yourself' => 'crwdns12244:0crwdne12244:0',
'incorrect_user_accepted' => 'crwdns1483:0crwdne1483:0',
@@ -54,6 +56,7 @@ return [
'ldap_could_not_get_entries' => 'crwdns1416:0crwdne1416:0',
'password_ldap' => 'crwdns1889:0crwdne1889:0',
'multi_company_items_assigned' => 'crwdns12754:0crwdne12754:0',
'no_pending_acceptances' => 'crwdns14720:0crwdne14720:0',
],
'deletefile' => [
+20 -25
View File
@@ -54,7 +54,7 @@ return [
'avatar_upload' => 'crwdns1029:0crwdne1029:0',
'back' => 'crwdns1030:0crwdne1030:0',
'bad_data' => 'crwdns1334:0crwdne1334:0',
'bulkaudit' => 'crwdns1915:0crwdne1915:0',
'bulkaudit' => 'crwdns14726:0crwdne14726:0',
'bulkaudit_status' => 'crwdns1916:0crwdne1916:0',
'bulk_checkout' => 'crwdns1667:0crwdne1667:0',
'bulk_edit' => 'crwdns6105:0crwdne6105:0',
@@ -122,7 +122,7 @@ return [
'debug_warning_text' => 'crwdns1828:0crwdne1828:0',
'delete' => 'crwdns1046:0crwdne1046:0',
'delete_confirm' => 'crwdns2020:0crwdne2020:0',
'delete_confirm_no_undo' => 'crwdns14510:0crwdne14510:0',
'delete_confirm_no_undo' => 'crwdns14568:0crwdne14568:0',
'deleted' => 'crwdns1047:0crwdne1047:0',
'delete_seats' => 'crwdns1430:0crwdne1430:0',
'deletion_failed' => 'crwdns6117:0crwdne6117:0',
@@ -154,7 +154,7 @@ return [
'first_checkout' => 'crwdns14484:0crwdne14484:0',
'generate' => 'crwdns1140:0crwdne1140:0',
'generate_labels' => 'crwdns6125:0crwdne6125:0',
'github_markdown' => 'crwdns1981:0crwdne1981:0',
'github_markdown' => 'crwdns14622:0crwdne14622:0',
'groups' => 'crwdns1054:0crwdne1054:0',
'gravatar_email' => 'crwdns1117:0crwdne1117:0',
'gravatar_url' => 'crwdns6127:0crwdne6127:0',
@@ -167,7 +167,7 @@ return [
'image_upload' => 'crwdns1058:0crwdne1058:0',
'filetypes_accepted_help' => 'crwdns12622:0crwdne12622:0',
'filetypes_size_help' => 'crwdns12624:0crwdne12624:0',
'image_filetypes_help' => 'crwdns14512:0crwdne14512:0',
'image_filetypes_help' => 'crwdns14570:0crwdne14570:0',
'unaccepted_image_type' => 'crwdns11365:0crwdne11365:0',
'import' => 'crwdns1411:0crwdne1411:0',
'documentation' => 'crwdns14462:0crwdne14462:0',
@@ -224,6 +224,9 @@ return [
'next_audit_date_help' => 'crwdns12196:0crwdne12196:0',
'audit_images_help' => 'crwdns12198:0crwdne12198:0',
'no_email' => 'crwdns12130:0crwdne12130:0',
'no_value' => 'crwdns14574:0crwdne14574:0',
'device_eol' => 'crwdns14576:0crwdne14576:0',
'na' => 'crwdns14578:0crwdne14578:0',
'last_audit' => 'crwdns1920:0crwdne1920:0',
'new' => 'crwdns1668:0crwdne1668:0',
'no_depreciation' => 'crwdns1073:0crwdne1073:0',
@@ -271,7 +274,7 @@ return [
'rtd' => 'crwdns14338:0crwdne14338:0',
'requested_date' => 'crwdns6149:0crwdne6149:0',
'requested_assets' => 'crwdns6151:0crwdne6151:0',
'requested_assets_menu' => 'crwdns14524:0crwdne14524:0',
'requested_assets_menu' => 'crwdns14572:0crwdne14572:0',
'request_canceled' => 'crwdns1703:0crwdne1703:0',
'request_item' => 'crwdns12204:0crwdne12204:0',
'external_link_tooltip' => 'crwdns12206:0crwdne12206:0',
@@ -558,6 +561,11 @@ return [
'error_user_company_accept_view' => 'crwdns11787:0crwdne11787:0',
'error_assets_already_checked_out' => 'crwdns13826:0crwdne13826:0',
'assigned_assets_removed' => 'crwdns13830:0crwdne13830:0',
'upload_files' => 'crwdns14580:0crwdne14580:0',
'uploaded_files' => 'crwdns14582:0crwdne14582:0',
'sign_in_place' => 'crwdns14700:0crwdne14700:0',
'sign_in_place_help' => 'crwdns14702:0crwdne14702:0',
'unauthorized' => 'crwdns14714:0crwdne14714:0',
'importer' => [
'checked_out_to_fullname' => 'crwdns11639:0crwdne11639:0',
'checked_out_to_first_name' => 'crwdns11641:0crwdne11641:0',
@@ -661,12 +669,16 @@ return [
'child_locations' => 'crwdns13796:0crwdne13796:0',
'append' => 'crwdns14416:0crwdne14416:0',
'optional' => 'crwdns14448:0crwdne14448:0',
'audit_by_field' => 'crwdns14728:0crwdne14728:0',
'audit_by_field_help' => 'crwdns14730:0crwdne14730:0',
'audit_key' => 'crwdns14732:0crwdne14732:0',
// Add form placeholders here
'placeholders' => [
'notes' => 'crwdns12878:0crwdne12878:0',
],
'last_note' => 'crwdns14584:0crwdne14584:0',
'bulk_delete_associations' => [
'general_assoc_warning' => 'crwdns13850:0crwdne13850:0',
'assoc_assets' => 'crwdns13852:0crwdne13852:0',
@@ -690,26 +702,6 @@ return [
'checkin_item' => 'crwdns12918:0crwdne12918:0',
],
'skins' => [
'site_default' => 'crwdns13057:0crwdne13057:0',
'default_blue' => 'crwdns13059:0crwdne13059:0',
'blue_dark' => 'crwdns13061:0crwdne13061:0',
'green' => 'crwdns13758:0crwdne13758:0',
'green_dark' => 'crwdns13065:0crwdne13065:0',
'red' => 'crwdns13760:0crwdne13760:0',
'red_dark' => 'crwdns13069:0crwdne13069:0',
'orange' => 'crwdns13762:0crwdne13762:0',
'orange_dark' => 'crwdns13073:0crwdne13073:0',
'black' => 'crwdns13075:0crwdne13075:0',
'black_dark' => 'crwdns13077:0crwdne13077:0',
'purple' => 'crwdns13079:0crwdne13079:0',
'purple_dark' => 'crwdns13081:0crwdne13081:0',
'yellow' => 'crwdns13083:0crwdne13083:0',
'yellow_dark' => 'crwdns13085:0crwdne13085:0',
'high_contrast' => 'crwdns13087:0crwdne13087:0',
],
'select_all_none' => 'crwdns12927:0crwdne12927:0',
'generic_model_not_found' => 'crwdns12920:0crwdne12920:0',
'report_not_editable' => 'crwdns14550:0crwdne14550:0',
@@ -753,4 +745,7 @@ return [
'months_plural' => 'crwdns13470:0crwdne13470:0',
'token_unrevoked' => 'crwdns14654:0crwdne14654:0',
'token_revoked' => 'crwdns14656:0crwdne14656:0',
];
+1 -1
View File
@@ -90,7 +90,7 @@ return [
'password' => 'crwdns12728:0crwdne12728:0',
'password_reset' => 'crwdns1750:0crwdne1750:0',
'read_the_terms' => 'crwdns1751:0crwdne1751:0',
'read_the_terms_and_click' => 'crwdns12072:0crwdne12072:0',
'read_the_terms_and_click' => 'crwdns14694:0crwdne14694:0',
'click_here_to_review_terms_and_accept_item' => 'crwdns14534:0crwdne14534:0',
'requested' => 'crwdns12730:0crwdne12730:0',
'reset_link' => 'crwdns1754:0crwdne1754:0',
+49 -9
View File
@@ -107,8 +107,49 @@ return [
],
'accessoriesfiles' => [
'name' => 'crwdns13980:0crwdne13980:0',
'note' => 'crwdns13982:0crwdne13982:0',
'note' => 'crwdns14590:0crwdne14590:0',
],
'assetsfiles' => [
'name' => 'crwdns14592:0crwdne14592:0',
'note' => 'crwdns14594:0crwdne14594:0',
],
'usersfiles' => [
'name' => 'crwdns14596:0crwdne14596:0',
'note' => 'crwdns14598:0crwdne14598:0',
],
'modelsfiles' => [
'name' => 'crwdns14600:0crwdne14600:0',
'note' => 'crwdns14602:0crwdne14602:0',
],
'departmentsfiles' => [
'name' => 'crwdns14604:0crwdne14604:0',
'note' => 'crwdns14606:0crwdne14606:0',
],
'suppliersfiles' => [
'name' => 'crwdns14608:0crwdne14608:0',
'note' => 'crwdns14610:0crwdne14610:0',
],
'locationsfiles' => [
'name' => 'crwdns14612:0crwdne14612:0',
'note' => 'crwdns14614:0crwdne14614:0',
],
'companiesfiles' => [
'name' => 'crwdns14616:0crwdne14616:0',
'note' => 'crwdns14618:0crwdne14618:0',
],
'consumablesfiles' => [
'name' => 'crwdns14000:0crwdne14000:0',
'note' => 'crwdns14620:0crwdne14620:0',
],
'consumables' => [
'name' => 'crwdns13984:0crwdne13984:0',
'note' => 'crwdns13986:0crwdne13986:0',
@@ -129,10 +170,7 @@ return [
'name' => 'crwdns13996:0crwdne13996:0',
'note' => 'crwdns13998:0crwdne13998:0',
],
'consumablesfiles' => [
'name' => 'crwdns14000:0crwdne14000:0',
'note' => 'crwdns14002:0crwdne14002:0',
],
'licenses' => [
'name' => 'crwdns14004:0crwdne14004:0',
'note' => 'crwdns14006:0crwdne14006:0',
@@ -161,6 +199,11 @@ return [
'name' => 'crwdns14024:0crwdne14024:0',
'note' => 'crwdns14026:0crwdne14026:0',
],
'componentsfiles' => [
'name' => 'crwdns14044:0crwdne14044:0',
'note' => 'crwdns14046:0crwdne14046:0',
],
'licenseskeys' => [
'name' => 'crwdns14028:0crwdne14028:0',
'note' => 'crwdns14030:0crwdne14030:0',
@@ -181,10 +224,7 @@ return [
'componentsdelete' => [
'name' => 'crwdns14042:0crwdne14042:0',
],
'componentsfiles' => [
'name' => 'crwdns14044:0crwdne14044:0',
'note' => 'crwdns14046:0crwdne14046:0',
],
'componentscheckout' => [
'name' => 'crwdns14048:0crwdne14048:0',
'note' => 'crwdns14050:0crwdne14050:0',
@@ -9,7 +9,7 @@ return [
'edit' => 'Wysig bykomstighede',
'eula_text' => 'Kategorie EULA',
'eula_text_help' => 'Hierdie veld laat u toe om u EULA\'s vir spesifieke soorte bates aan te pas. As u slegs een EULA vir al u bates het, kan u die onderstaande boks selekteer om die primêre standaard te gebruik.',
'require_acceptance' => 'Vereis gebruikers om die aanvaarding van bates in hierdie kategorie te bevestig.',
'require_acceptance' => 'Require users to confirm acceptance of item in this category.',
'no_default_eula' => 'Geen primêre standaard EULA gevind nie. Voeg een by Instellings.',
'total' => 'totale',
'remaining' => 'opgelewer',
@@ -17,6 +17,7 @@ return [
'name' => 'Kategorie Naam',
'require_acceptance' => 'Vereis gebruikers om die aanvaarding van bates in hierdie kategorie te bevestig.',
'required_acceptance' => 'Hierdie gebruiker sal per e-pos met \'n skakel gestuur word om die aanvaarding van hierdie item te bevestig.',
'global_signature_required_notice' => 'User signatures are currently required globally via the admin settings, so signatures will still be required regardless of this category setting if the item is checked out to a user (versus a location, etc).',
'required_eula' => 'Hierdie gebruiker sal \'n afskrif van die EULA ontvang',
'no_default_eula' => 'Geen primêre standaard EULA gevind nie. Voeg een by Instellings.',
'update' => 'Opdateer kategorie',
@@ -5,7 +5,7 @@ return [
'manage' => 'Manage',
'field' => 'veld',
'about_fieldsets_title' => 'Oor Fieldsets',
'about_fieldsets_text' => 'Veldstelle stel jou in staat om groepe van persoonlike velde te skep wat gereeld hergebruik word vir spesifieke tipe bates.',
'about_fieldsets_text' => 'Fieldsets allow you to create groups of custom fields that are frequently re-used for specific asset model types.',
'custom_format' => 'Custom Regex format...',
'encrypt_field' => 'Enkripteer die waarde van hierdie veld in die databasis',
'encrypt_field_help' => 'WAARSKUWING: Om \'n veld te enkripteer, maak dit onondersoekbaar.',
@@ -7,5 +7,6 @@ return [
'invalid_return_value' => 'Invalid value returned from :name. Expected :expected, got :actual.',
'does_not_exist' => 'Label does not exist',
'use_new_label_engine_for_api' => 'Enable the New Label Engine to load labels via the API',
'label_not_created' => 'Label object could not be created',
];
@@ -8,7 +8,6 @@ return [
'assoc_child_loc' => 'Hierdie ligging is tans die ouer van ten minste een kind se plek en kan nie uitgevee word nie. Werk asseblief jou liggings by om nie meer hierdie ligging te verwys nie en probeer weer.',
'assigned_assets' => 'Assigned Assets',
'current_location' => 'Current Location',
'open_map' => 'Open in :map_provider_icon Maps',
'deleted_warning' => 'This location has been deleted. Please restore it before attempting to make any changes.',
'create' => [
@@ -2,13 +2,13 @@
return [
'select_type' => 'Select Maintenance Type',
'asset_maintenance_type' => 'Asset Maintenance Type',
'asset_maintenance_type' => 'tipe',
'title' => 'Titel',
'start_date' => 'Start Date',
'completion_date' => 'Completion Date',
'cost' => 'koste',
'is_warranty' => 'Garantieverbetering',
'asset_maintenance_time' => 'Asset Maintenance Time (in days)',
'asset_maintenance_time' => 'Duration',
'notes' => 'notas',
'update' => 'Update Asset Maintenance',
'create' => 'Create Asset Maintenance',
@@ -309,7 +309,7 @@ return [
'two_factor_enabled_edit_not_allowed' => 'Jou administrateur laat jou nie toe om hierdie instelling te wysig nie.',
'two_factor_enrollment_text' => 'Twee faktor verifikasie is nodig, maar jou toestel is nog nie ingeskryf nie. Maak jou Google Authenticator-program oop en scan die QR-kode hieronder om jou toestel in te skryf. Sodra jy jou toestel ingeskryf het, voer die kode hieronder in',
'require_accept_signature' => 'Vereis Handtekening',
'require_accept_signature_help_text' => 'As u hierdie kenmerk aanskakel, sal gebruikers fisies moet afmeld wanneer hulle \'n bate aanvaar.',
'require_accept_signature_help_text' => 'Enabling this feature will require users to physically sign off on accepting items. This will override any category-specific signature requirements.',
'require_checkinout_notes' => 'Require Notes on Checkin/Checkout',
'require_checkinout_notes_help_text' => 'Enabling this feature will require the note fields to be populated when checking in or checking out an asset.',
'left' => 'links',
@@ -324,15 +324,30 @@ return [
'username_format_help' => 'This setting will only be used by the import process if a username is not provided and we have to generate a username for you.',
'oauth_title' => 'OAuth API Settings',
'oauth_clients' => 'OAuth Clients',
'oauth' => 'OAuth',
'oauth_help' => 'Oauth Endpoint Settings',
'oauth' => 'OAuth & API',
'oauth_help' => 'Oauth Endpoint Settings and API tokens',
'oauth_no_clients' => 'You have not created any OAuth clients yet.',
'oauth_no_applications' => 'You have not created any Authorized Applications yet.',
'oauth_secret' => 'Secret',
'oauth_authorized_apps' => 'Authorized Applications',
'oauth_redirect_url' => 'Redirect URL',
'oauth_name_help' => ' Something your users will recognize and trust.',
'oauth_scopes' => 'Scopes',
'oauth_client_type' => 'Client Type',
'oauth_client_type_oauth' => 'OAuth Client',
'oauth_client_type_personal_access' => 'Personal Access Client',
'oauth_client_type_password_grant' => 'Password Grant Client',
'oauth_associated_token_count' => 'Token Count',
'oauth_callback_url' => 'Your application authorization callback URL.',
'oauth_personal_access_tokens' => 'User API Tokens (Personal Access Tokens)',
'oauth_personal_access_tokens_none' => 'No personal access tokens found.',
'oauth_client' => 'Client',
'oauth_deleted_user' => 'Deleted user (ID: :id)',
'oauth_token_status_revoked' => 'Revoked',
'oauth_token_status_expired' => 'Expired',
'oauth_token_status_active' => 'aktiewe',
'oauth_revoke' => 'Revoke',
'oauth_unrevoke' => 'Unrevoke',
'create_client' => 'Create Client',
'no_scopes' => 'No scopes',
'asset_tag_title' => 'Update Asset Tag Settings',
@@ -346,6 +361,7 @@ return [
'general_title' => 'Update General Settings',
'mail_test' => 'Send Test',
'mail_test_help' => 'This will attempt to send a test mail to :replyto.',
'mail_test_no_email' => 'MAIL_REPLYTO_ADDR not set or has no value .env config file. Cannot send test email. Please update this value in your configuration file with a valid email address.',
'filter_by_keyword' => 'Filter by setting keyword',
'security' => 'Security',
'security_title' => 'Update Security Settings',
@@ -525,6 +541,7 @@ return [
'purge' => 'permanently delete',
'security' => 'password, passwords, requirements, two factor, two-factor, common passwords, remote login, logout, authentication',
'notifications' => 'alerts, email, notifications, audit, threshold, email alerts, cc',
'oauth' => 'oauth, oath, api, personal access keys, tokens',
],
];
@@ -56,4 +56,21 @@ return [
'not_saved' => 'Your settings were not saved.',
'mismatch' => 'There is 1 item in the database that need your attention before you can enable location scoping.|There are :count items in the database that need your attention before you can enable location scoping.',
],
'oauth' => [
'token_revoked' => 'Personal access token revoked successfully.',
'token_unrevoked' => 'Personal access token reinstated successfully.',
'token_not_found' => 'That personal access token could not be found.',
'token_revoke_error' => 'An error occurred while revoking the token.',
'token_unrevoke_error' => 'An error occurred while reinstating the token.',
'client_created' => 'OAuth client created successfully.',
'client_updated' => 'OAuth client updated successfully.',
'client_deleted' => 'OAuth client deleted successfully.',
'client_revoked' => 'OAuth client revoked successfully.',
'client_unrevoked' => 'OAuth client reinstated successfully.',
'client_not_found' => 'That OAuth client could not be found.',
'token_deleted' => 'Token revoked successfully.',
'client_delete_denied' => 'You are not authorized to delete this client.',
'client_edit_denied' => 'You are not authorized to edit this client.',
'token_delete_denied' => 'You are not authorized to revoke this token.',
],
];

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