Compare commits

..

365 Commits

Author SHA1 Message Date
snipe 3680198d61 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-06-13 19:22:01 +01:00
snipe da86e919d9 Bumped version to v8.6.2 2026-06-13 19:19:50 +01:00
snipe c98eea1ce5 Merge remote-tracking branch 'origin/develop' 2026-06-13 18:02:13 +01:00
snipe 45d6a491cb Localization: Fixed #19173 - use translatoon string for depreciation helper 2026-06-13 18:02:00 +01:00
snipe 3f77a6eeab Merge remote-tracking branch 'origin/develop' 2026-06-13 17:57:19 +01:00
snipe 3dc90f89f6 Localization: Fixed #19176 - use translation key for “send email” 2026-06-13 17:57:09 +01:00
snipe 5333837b20 Merge remote-tracking branch 'origin/develop' 2026-06-13 14:51:29 +01:00
snipe e2bea57146 Merge pull request #19167 from grokability/fmcs-scope-check-updates-for-multiple-companies
FMCS/Console: Fixed #19166 scope check updates for multiple companies, adds floater
2026-06-13 14:51:01 +01:00
snipe 43a32071f1 FMCS/Companyable Trait: refactor API call to use canCheckoutTo 2026-06-13 14:38:32 +01:00
snipe e3a9872d28 Updated tests 2026-06-13 13:18:37 +01:00
snipe 75f86cd669 FMCS+location+floater+importer: handle the importer as well 2026-06-13 12:55:10 +01:00
snipe 73f72cbbb0 Use the new companyable trait in the bulk assets controller 2026-06-13 12:42:48 +01:00
snipe 2033f25386 FMCS/Floater: Refactor logic into the companyable trait 2026-06-13 12:36:15 +01:00
snipe 8d0a6af2aa Refactor into the Companyable trait 2026-06-13 12:35:48 +01:00
snipe 2aae0cb793 Bumped hash 2026-06-12 22:27:32 +01:00
snipe caf5e347e3 Merge remote-tracking branch 'origin/develop' 2026-06-12 22:26:31 +01:00
snipe a698ba3082 Dev assets 2026-06-12 22:25:47 +01:00
snipe b57d286b15 Bumped hash 2026-06-12 22:25:30 +01:00
snipe 3cd5e86527 Updated language strings 2026-06-12 22:23:44 +01:00
snipe 31cf7ba6b5 Merge remote-tracking branch 'origin/develop' 2026-06-12 22:04:38 +01:00
snipe bccba46332 Merge pull request #19170 from marcusmoore/fixes/21586-importer-error-handling
Importer: allow rendering simple error messages
2026-06-12 22:02:52 +01:00
snipe 70357ada3d Merge pull request #19175 from marcusmoore/fixes/21665-filter-request-validation
Reporting: improve validation for item and target types
2026-06-12 22:02:30 +01:00
snipe 6769424aa2 Merge remote-tracking branch 'origin/develop' 2026-06-12 21:52:43 +01:00
snipe 043ad713e7 Merge pull request #19115 from grokability/#19096-and-#19095-company-and-location-and-groups-in-scim
Fixed #19095 and #19096 - SCIM updates in Azure/Entra
2026-06-12 21:52:32 +01:00
snipe bb178b0a5c Merge pull request #19183 from uberbrady/#19096-and-#19095-company-and-location-and-groups-in-scim
SCIM: Fix address handling and work around Entra email changes
2026-06-12 21:51:37 +01:00
Brady Wetherington 288bded7d9 SCIM: Fix address handling and work around Entra email changes 2026-06-12 21:43:47 +01:00
snipe 5c87735218 Merge remote-tracking branch 'origin/develop' 2026-06-12 19:55:18 +01:00
snipe d12ad3d538 Table row selection: Use document.getElementById and DOM/jQuery element constructors 2026-06-12 19:55:07 +01:00
snipe f269cfec34 Merge remote-tracking branch 'origin/develop' 2026-06-12 19:18:01 +01:00
snipe 905d498ecd Maintenances: Fixed FD-55977 - Cross-company asset maintenance re-parenting via API update 2026-06-12 19:17:44 +01:00
snipe 802067f398 Acceptances: Fixed FD-55978 - Cross-company deletion of pending checkout acceptances via unscoped report endpoint 2026-06-12 19:15:36 +01:00
snipe a212f87acd Merge remote-tracking branch 'origin/develop' 2026-06-12 18:53:31 +01:00
snipe b40e227ad3 Merge pull request #19182 from uberbrady/fix_too_many_placeholders
Custom Asset Report: Fixed [RB-21669] - use subquery for action_date
2026-06-12 18:45:22 +01:00
Brady Wetherington b89504e1c3 Custom Asset Report: Fixed [RB-21669] - use subquery for action_log action_date 2026-06-12 17:49:48 +01:00
snipe c8ae09cd43 FMCS location scoping - Added tests 2026-06-12 17:26:57 +01:00
snipe 8ebddd95ff FMCS+location scoping - Fixed scope boundaries 2026-06-12 16:46:23 +01:00
snipe c14880dfca Oh, pint 2026-06-12 16:10:29 +01:00
snipe a27c551f64 Style changes requested 2026-06-12 16:10:17 +01:00
Marcus Moore e71453cb5d Reporting: re-add camel and pascal casing for asset model and license seat 2026-06-11 12:41:49 -07:00
Marcus Moore bb19add3b6 Reporting: improve validation 2026-06-11 12:25:40 -07:00
snipe 9bd6396a15 Merge pull request #19162 from marcusmoore/fixes/21663-audit-notification
Auditing: Added try catch around sending notification
2026-06-11 00:41:07 +01:00
Marcus Moore b060327219 Importer: allow rendering simple error messages
[RB-21586]
2026-06-10 11:51:03 -07:00
Marcus Moore 8b383df13f Logging: use warning instead of error 2026-06-10 09:53:30 -07:00
snipe 6a0ec69451 FMCS/Validation: Fixed #19166 - translate error messages on FMCS fail 2026-06-10 12:44:46 +01:00
snipe 66bf6275b8 Merge branch 'develop' into fmcs-scope-check-updates-for-multiple-companies 2026-06-10 12:34:09 +01:00
snipe 6fc2ff7252 FMCS console checker: added test 2026-06-10 12:15:28 +01:00
snipe 0f6367bb17 FMCS: Extended checks to accessories, bulk controllers, etc 2026-06-10 11:47:54 +01:00
snipe e3190c3922 FMCS Admin Settings: updated language 2026-06-10 11:41:02 +01:00
snipe 53628d6ae3 FMCS: Users API - Check for floater in results 2026-06-10 11:26:41 +01:00
snipe d03f68ae34 FMCS: Updated floater value in controller 2026-06-10 11:26:12 +01:00
snipe 87bc834885 FMCS: updated language strings (may tweak) 2026-06-10 11:25:13 +01:00
snipe 9f89dffaae FMCS: update the helper that checks for location-scoping 2026-06-10 11:23:14 +01:00
snipe ab1a5c0241 FMCS: check for floater mode in user and company 2026-06-10 11:22:50 +01:00
snipe 758c1cabc5 FMSC Tests: added enableFloaterMode for setup 2026-06-10 11:20:55 +01:00
snipe 3dd5358e73 FMCS: updated/added tests 2026-06-10 11:20:33 +01:00
snipe 8cc4ad27c9 FMCS: Added floater option checkbox to general settings 2026-06-10 11:20:05 +01:00
snipe cd1f6b8e73 FMCS: Added floater option in admin settings 2026-06-10 11:19:39 +01:00
snipe 07e70cf7a9 Added test 2026-06-10 11:19:16 +01:00
snipe a9d1069705 Merge pull request #19161 from Godmartinz/assignedTo_fix
FD[55918] Fixes location not displaying on labels when assigned to.
2026-06-09 23:04:30 +01:00
snipe 9967d1e784 Merge remote-tracking branch 'origin/develop' 2026-06-09 20:33:40 +01:00
snipe 10703263a8 Livewire: Added ComponentNotFoundException to $dontReport
A bot was POSTing a crafted payload to that endpoint requesting the `filament.pages.dashboard` component - a known Filament probe - while posting to the . Livewire resolved the route, couldn't find that component class, and threw `ComponentNotFoundException` uncaught, resulting in a 500.

`ComponentNotFoundException` is now in `$dontReport` (so it won't flood our error tracker) and returns a 404 JSON response, the same pattern already used for `PublicPropertyNotFoundException`. The bot gets a 404 and moves on, no more 500s.
2026-06-09 20:33:27 +01:00
snipe 1697d10f16 Merge remote-tracking branch 'origin/develop' 2026-06-09 19:08:39 +01:00
snipe b0aa21bee7 FMCS: throw an error if companies don’t match, updated tests 2026-06-09 19:08:27 +01:00
Marcus Moore 82fa1d7a26 Auditing: wrap audit notification in try catch 2026-06-09 11:04:22 -07:00
Godfrey M be446e97d7 Labels: use instanceOf to differentiate accessors 2026-06-09 10:55:15 -07:00
snipe c44f3319e3 Fixed company quirk with multi-company users creating assets, etc 2026-06-09 18:50:49 +01:00
Godfrey M 678d1c1428 Labels: return full_name instead of display_name for assignedTo 2026-06-09 10:50:18 -07:00
Godfrey M 535d7c0ff6 Label Fields: Fixes location assignedTos coming back null 2026-06-09 10:38:43 -07:00
snipe df4069375f Merge remote-tracking branch 'origin/develop' 2026-06-09 13:02:19 +01:00
snipe e430e4e6e2 Tests: Added explicit sort on tests to fix flakiness 2026-06-09 13:02:05 +01:00
snipe 0d2af35420 Merge remote-tracking branch 'origin/develop' 2026-06-09 12:47:23 +01:00
snipe df92076e15 Added notes for FMCS scoping 2026-06-09 12:47:11 +01:00
snipe e2ba35ee80 Small FMCS fixes 2026-06-09 12:33:48 +01:00
snipe f4cac96358 Apply scope to print page 2026-06-09 12:18:50 +01:00
snipe 8e8a3f2d24 Merge remote-tracking branch 'origin/develop' 2026-06-08 22:38:30 +01:00
snipe 5257c2ce84 Merge pull request #19158 from grokability/added-qr-codes-to-non-assets
QR Codes: Added QR codes for non-assets
2026-06-08 22:38:16 +01:00
snipe b378cf31f4 Merge pull request #19160 from grokability/added-changed-log-meta-to-accessories-and-licenses
Logging: Fixed FD-55757 - Added changed log meta to accessories and licenses
2026-06-08 22:37:59 +01:00
snipe 0f184840df Pint 2026-06-08 22:29:53 +01:00
snipe 3df21df85b Logging: Fixed FD-55757 - added log_meta for licenses and accessories 2026-06-08 22:29:44 +01:00
snipe 7b679b7df4 Merge remote-tracking branch 'origin/develop' 2026-06-08 21:55:28 +01:00
snipe 0d870d540d Kits: Fixed FD-55737 - Kit License Association Lacks Object-Level Authorization 2026-06-08 21:55:16 +01:00
snipe d626770c91 Merge remote-tracking branch 'origin/develop' 2026-06-08 21:43:16 +01:00
snipe 144772cfbe Fixed tests 2026-06-08 21:41:22 +01:00
snipe 80c8aa41dc License Checkin (legacy): Fixes FD-55734 - License Single-Seat Checkin Uses Incorrect Permission Check 2026-06-08 20:59:10 +01:00
snipe 5658cd6dd4 Reports (legacy): Fixed FD-55739 - Use CSV escaping on legacy depreciation and license reports 2026-06-08 20:40:03 +01:00
snipe 374f426f0c Bulk checkin (with optional delete) users: Tightened the gates to check for more specific checkin permissions 2026-06-08 20:30:43 +01:00
snipe 2af0c237a9 Security+SAML: Check the redirect option for host validation 2026-06-08 20:29:59 +01:00
snipe dafd72af59 Users UI: Check whether the user can see assets, etc on user view page 2026-06-08 20:13:47 +01:00
snipe 4a3bd51d9c Merge remote-tracking branch 'origin/develop' 2026-06-08 17:08:44 +01:00
snipe cbc6dc94a5 Licenses/Accessory/Consumables: Fixed FD-55732 - confirm FMCS on backend 2026-06-08 17:08:18 +01:00
snipe 10ceb5a858 Merge remote-tracking branch 'origin/develop' 2026-06-08 17:03:36 +01:00
snipe f74e7510c5 Licenses Checkout: Fixed FD-55733 - License Bulk Checkout Uses Incorrect Permission Check 2026-06-08 17:03:16 +01:00
snipe 80fd4798ce Merge remote-tracking branch 'origin/develop' 2026-06-08 16:57:36 +01:00
snipe d87cd7cbb9 Users Merge: Fixed FD-55767 - added canEditAuthFields for users in merge 2026-06-08 16:57:05 +01:00
snipe 910b726e34 Merge remote-tracking branch 'origin/develop' 2026-06-08 16:52:17 +01:00
snipe 9a8cbd6e00 API: Fixed FD-55735- API Location Creation Bypasses FMCS Parent-Child Company Boundary Validation 2026-06-08 16:52:05 +01:00
snipe abc4363e83 Fixed FD-55839 - arbitrary file deletion 2026-06-08 16:48:18 +01:00
snipe 006981cccf Merge remote-tracking branch 'origin/develop' 2026-06-08 16:36:35 +01:00
snipe df0ee6020a Fixed FD-55803 - escape links 2026-06-08 16:36:17 +01:00
snipe 000cea0a62 Merge remote-tracking branch 'origin/develop' 2026-06-08 16:32:03 +01:00
snipe 53599544af Fixed FD-55751 - check for safe inline, force download otherwise 2026-06-08 16:31:52 +01:00
snipe b5ec9e080d QR Codes: Added QR codes for non-assets 2026-06-08 16:19:21 +01:00
snipe beb593b37e Merge remote-tracking branch 'origin/develop' 2026-06-08 14:39:16 +01:00
snipe 8f98c8a862 Accessory checkouts: Fixed #19154 - get checkout company by way of parent accessory 2026-06-08 14:38:49 +01:00
snipe 9d518ec39f Merge remote-tracking branch 'origin/develop' 2026-06-08 14:04:14 +01:00
snipe 0959d87534 Pint 2026-06-08 14:04:00 +01:00
snipe 1252681d55 API pagination: Fixed #19155 - API not paginating correctly with page=x, added tests 2026-06-08 14:03:47 +01:00
snipe 9bc4efa5ff Disable FK checks on seeder 2026-06-08 13:43:06 +01:00
snipe 6116f1a9fe Merge remote-tracking branch 'origin/develop' 2026-06-04 18:22:50 +01:00
snipe 5656e4f5b7 Fixed #19136 - translate strings on importer 2026-06-04 18:22:37 +01:00
snipe 86ba1f56fb Merge remote-tracking branch 'origin/develop' 2026-06-04 18:13:54 +01:00
snipe a966198a75 Fixed #19143 - dynamic URL for support URL on manufacturer 2026-06-04 18:13:42 +01:00
snipe 4ff214ac47 Component-ify companies 2026-06-04 16:53:34 +01:00
snipe 5169d174ad Merge pull request #19144 from uberbrady/#19096-and-#19095-company-and-location-and-groups-in-scim
Fix to SCIM companies, and some PHP errors around inheritance
2026-06-04 16:45:28 +01:00
snipe 9c849c337f Merge pull request #19103 from Godmartinz/google_chat_null_bug
[FD-55583] Fixed google webhook check in notification
2026-06-04 14:51:46 +01:00
snipe d0685464f6 Merge pull request #19142 from grokability/components-component-blade
Components component blade
2026-06-04 14:50:29 +01:00
snipe 10b5a8ef21 Use qty component 2026-06-04 14:42:25 +01:00
snipe f0a9a49753 Update components to use… blade components 2026-06-04 14:34:32 +01:00
Brady Wetherington 1afde946d2 Fix to SCIM companies, and some PHP errors around inheritance 2026-06-04 14:25:06 +01:00
snipe a2a2de9718 Merge remote-tracking branch 'origin/develop' 2026-06-04 13:56:45 +01:00
snipe e8ba1feddc Fixed RDS database single transaction setting 2026-06-04 13:56:33 +01:00
snipe 18e9b5c5bf Merge pull request #19141 from grokability/consumables-blade-components
Updated consumables to use blade components
2026-06-04 13:48:00 +01:00
snipe f186dc20f6 Updated consumables to use blade components 2026-06-04 13:40:21 +01:00
snipe 80a722d465 Merge pull request #19140 from grokability/accessories-component-blades
Accessories component blades
2026-06-04 13:26:22 +01:00
snipe 765487f62e Added form-static blade 2026-06-04 13:23:14 +01:00
snipe 1d186fffaa Use display_name 2026-06-04 13:19:32 +01:00
snipe 6295b7726e Added breadcrumb trail for checkin/checkout/clone 2026-06-04 13:06:45 +01:00
snipe e7c45644b9 Blade-ify the accessories views 2026-06-04 13:00:31 +01:00
snipe a5fa1f5b97 Merge remote-tracking branch 'origin/develop' 2026-06-03 20:39:09 +01:00
snipe 356a0d4c12 Fixed RB-20978 - Header may not contain more than a single header, new line detected
When edit() is called, it stores url()->previous() (the Referer header) as url.intended. When update() is called after, getRedirectOption() pulls that URL out of the session and uses it as a Location header. If that URL ever contains a \n or \r\n - whether from a crafted Referer header, a stale SAML RelayState, or a proxy quirk - PHP's header() function raises this exception as a header injection safeguard.
2026-06-03 20:38:50 +01:00
snipe bc80f5eb55 Merge remote-tracking branch 'origin/develop' 2026-06-03 12:30:21 +01:00
snipe 00d4d6c7a8 Don’t strip comany association if company_id is passed to the user (old integrations) 2026-06-03 12:30:08 +01:00
snipe ed1e89d5be Merge remote-tracking branch 'origin/develop' 2026-06-03 11:59:31 +01:00
snipe 371d44b2a7 Fixed weird escaping in BS table export when text is hyperlinked 2026-06-03 11:59:19 +01:00
snipe 79732a9151 Fixed #19120 - added DB_DUMP_SINGLE_TRANSACTION to .env for RDS support 2026-06-03 11:40:33 +01:00
snipe a6e55fb462 Fixed #19130 - normalize null and 0 in permissions array, since they mean the same 2026-06-03 11:33:48 +01:00
snipe d032a51a3d Validate comapny exists 2026-06-03 11:26:23 +01:00
snipe 9c2495af29 Fixed #19131 - tighter validation for company_id/company_ids 2026-06-03 11:21:44 +01:00
snipe dbcd2e54ea Merge remote-tracking branch 'origin/develop' 2026-06-03 11:17:26 +01:00
snipe d7bc6c45f6 Merge pull request #19135 from grokability/#19133-add-optional-clear-name-to-quick-scan-and-bulk-audit
🎥 Fixed #19133 - added optional clear asset name to quick scan checkin/audit
2026-06-03 11:17:12 +01:00
snipe 4382e01f57 Added tests 2026-06-03 10:52:11 +01:00
snipe bab5294399 Fixed #19133 - added optional clear asset name to quick scan checkin/audit 2026-06-03 10:52:04 +01:00
snipe 7122a79afe Merge remote-tracking branch 'origin/develop' 2026-06-03 10:36:57 +01:00
snipe a161fa8519 Fix company syncing in bulk editing users
If the target user belongs to [A, B, C] and the acting admin belongs to [B, C], only B and C get detached. Company A — which the acting admin can't see — is left untouched.
2026-06-03 10:36:46 +01:00
snipe 5e5bd7a17d Merge pull request #19132 from marcusmoore/fixes/19128-settings-markdown
Fixed #19128: Rendering on general settings page
2026-06-02 21:09:51 +01:00
Marcus Moore 285717ab12 Closing icon properly 2026-06-02 10:01:23 -07:00
snipe 81d91da0b8 Merge pull request #19127 from marcusmoore/55765-supplier-note-length
Bump note length validation for supplier model
2026-06-02 07:57:45 +01:00
Marcus Moore b017e9382f Bump note length validation for supplier 2026-06-01 16:53:36 -07:00
snipe db325483fe Merge remote-tracking branch 'origin/develop' 2026-06-01 18:47:14 +01:00
snipe eb5334e865 Fixed #18953 - removed excel as dropdown option for BS-table export
The 'excel' export type in the bootstrap-table config generates an HTML document with MSO namespace tags disguised as an .xls file.

Excel recognizes the mismatch and warns. The 'xlsx' type (also in the list) uses SheetJS to generate a real .xlsx file.
2026-06-01 18:43:10 +01:00
snipe 8d9e3444c4 Merge remote-tracking branch 'origin/develop' 2026-06-01 18:25:57 +01:00
snipe 01b1c3923d Fixed #19119 - updated structure for accessort export, added tests 2026-06-01 18:25:43 +01:00
snipe 861f061f0f Merge remote-tracking branch 'origin/develop' 2026-06-01 17:29:33 +01:00
snipe 780fb76af8 Added jfif to extension list in config 2026-06-01 17:29:02 +01:00
snipe 85df607edc Merge remote-tracking branch 'origin/develop' 2026-05-30 18:28:00 +01:00
snipe ab90fc16e0 Merge pull request #19118 from grokability/check-in-and-delete-cli-refactor
Check in and delete by company via command line
2026-05-30 18:27:23 +01:00
snipe 990c50c5b9 Prevent the admin (acting) user from still being associated with a deleted company (if deleting the comapny was selected) 2026-05-30 18:13:23 +01:00
snipe 2e91b3dc9a Remove the pivot company record for asking user if the “delete company” option is selected 2026-05-30 17:59:35 +01:00
snipe 211bd02786 Small UI improvements 2026-05-30 17:52:06 +01:00
snipe e8d000a17a Restrict admin user search to superadmins only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:35:39 +01:00
snipe 8fc373abfc Ask to delete the companies themselves, suppress backup noise 2026-05-30 12:29:02 +01:00
snipe a473ca737e Added test 2026-05-30 12:26:11 +01:00
snipe ff6fc68981 Added mailer 2026-05-30 12:26:04 +01:00
snipe f133a67550 Added email blade 2026-05-30 12:25:36 +01:00
snipe b0a6cdc29f Added delete cli prompt tool 2026-05-30 11:47:52 +01:00
snipe 8f605e04cb Merge remote-tracking branch 'origin/develop' 2026-05-30 10:22:34 +01:00
snipe edcb429366 Fixed test 2026-05-30 10:18:46 +01:00
snipe 8b52b4684d Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	public/css/build/app.css
#	public/css/build/app.css.map
#	public/css/build/overrides.css
#	public/css/build/overrides.css.map
#	public/css/dist/all.css
#	public/mix-manifest.json
2026-05-30 10:05:28 +01:00
snipe ba5a674526 Fixed FD-55720 check on legacy route 2026-05-30 10:04:00 +01:00
snipe e84496f8b1 Fixed kit gate 2026-05-30 09:40:37 +01:00
snipe 5f9212383a Merge pull request #19117 from grokability/#19086-added-markdown-custom-fields
🎥 Fixed #19086 - added markdown textarea custom fields
2026-05-30 09:30:01 +01:00
snipe a5493f11bc Fixed weird HTML on showing encrypted fields on click 2026-05-30 09:13:11 +01:00
snipe ce434b3d04 Added thead to remove the weird dupe footer when we’re using icon headers 2026-05-29 20:53:54 +01:00
snipe ade07b411b Added helper and CSS 2026-05-29 17:37:12 +01:00
snipe d877688f32 Merge remote-tracking branch 'origin/develop' 2026-05-29 12:23:48 +01:00
snipe 3868e469c0 Merge pull request #19104 from Godmartinz/gh19083-assgnedTo-displayname-split
Adds #19083 display name as an option for label field options
2026-05-29 12:22:40 +01:00
snipe ea939acbd3 Support multi-company in SCIM sync 2026-05-29 11:37:11 +01:00
snipe 522544c131 Fixed #19095 and #19096 - SCIM updates in Azure/Entra 2026-05-29 11:22:43 +01:00
snipe fad2655357 Prod assets 2026-05-29 10:59:58 +01:00
snipe 445fb6f253 Merge remote-tracking branch 'origin/master' into develop 2026-05-29 10:58:33 +01:00
snipe 7bf8fd5eeb Fixed #19106 - tighten up user accessor to treat null and empty string the same 2026-05-29 10:55:53 +01:00
snipe c758fb4c83 Pint 2026-05-29 10:50:31 +01:00
snipe 4145f64399 Exclude current id on checkin pages 2026-05-29 10:50:10 +01:00
snipe 4120ab6fe6 Pint 2026-05-29 10:40:21 +01:00
snipe 0170fb7711 Added test for location scoping 2026-05-29 10:39:03 +01:00
snipe 42df2f6c31 One more fix for #19112 2026-05-29 09:37:23 +01:00
snipe 9b522b69ff Fixed #19112 - company list disabled 2026-05-29 09:13:03 +01:00
snipe 135db70b0f Fixed #19100 - check all companies a user belongs to for asset assignment 2026-05-29 08:50:34 +01:00
snipe 048e97f9a9 Added query count test 2026-05-29 08:34:34 +01:00
snipe 18d8f257ee Merge pull request #19108 from grokability/fixed-fd-55710-flattern-queries
Fixed FD-55710 - flatten EXIST queries
2026-05-29 01:42:31 +01:00
snipe ec67195014 Removed a few duplicate queries 2026-05-29 01:34:11 +01:00
snipe 0d745ad10f Added view composer forn sidebar counts, removed sidebar middleware 2026-05-29 01:30:34 +01:00
snipe 89ce71b350 Flatten queries 2026-05-29 00:54:03 +01:00
snipe 63c1f7922f Added test 2026-05-29 00:53:48 +01:00
snipe 5809ac7997 Removed $with 2026-05-29 00:53:21 +01:00
Godfrey M 92b6e46249 adds display name as an option for labels 2026-05-28 11:01:26 -07:00
Godfrey M 46c11d8599 add nullsafe operator to item location in google chat message 2026-05-28 09:44:37 -07:00
snipe 7651365ff6 Merge pull request #19102 from Godmartinz/update-asset_not_deployable_to_translation_choice
fixes not deployable translation usage
2026-05-28 17:17:59 +01:00
Godfrey M 35caa0e68d fix translation to use choice 2026-05-28 09:00:23 -07:00
snipe e0a7fe443d Merge remote-tracking branch 'origin/develop' 2026-05-28 12:35:48 +01:00
snipe c31190a128 Merge pull request #19097 from grokability/fd-55359-css-validation
Fixed FD-55359 - adds CSS color validation
2026-05-28 12:35:24 +01:00
snipe de50ec30b7 Pint, of course 2026-05-28 12:25:22 +01:00
snipe c0fe308d7d Fixed FD-55359 - sanitize CSS 2026-05-28 12:25:12 +01:00
snipe 0a20141b7c Removed bulk action for deleted 2026-05-28 11:29:49 +01:00
snipe 4d0282ca0a Merge remote-tracking branch 'origin/develop' 2026-05-28 11:06:31 +01:00
snipe e61143f746 Allow checkin from deleted view 2026-05-28 11:06:13 +01:00
snipe 69dc91d225 Show deleted_at date in table if looking at deleted assets 2026-05-28 10:45:07 +01:00
snipe 6f1c49e14d Fixed #19089 - show uploads in activity report for companies, models, etc 2026-05-28 10:14:30 +01:00
snipe 78acc3685d Updated icons 2026-05-28 08:52:01 +01:00
snipe bf5013e527 Prod assets 2026-05-27 15:21:24 +01:00
snipe cbd961e922 Merge remote-tracking branch 'origin/develop' 2026-05-27 15:20:55 +01:00
snipe fa26e23383 Fixed #19088 - added suppliers transformer 2026-05-27 15:20:26 +01:00
snipe 7abe1bed50 Merge remote-tracking branch 'origin/develop' 2026-05-27 10:46:49 +01:00
snipe 155df0a94d Fixed multi-select edge case 2026-05-27 10:36:19 +01:00
snipe 4d06e81768 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-05-27 09:42:05 +01:00
snipe 9bf1e2401d Bumped minor release 2026-05-27 09:41:00 +01:00
snipe 4edf40acaf Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-05-27 09:26:55 +01:00
snipe a54ed750a3 Fixed if/else 2026-05-27 09:23:05 +01:00
snipe 4d2416ab96 Bumped mid-version 2026-05-26 23:21:55 +01:00
snipe 61ae30528a Merge pull request #19079 from marcusmoore/composer-lock-update
Updated composer lock file
2026-05-26 23:20:06 +01:00
Marcus Moore e883eb70b9 Update lock file
composer update --lock
2026-05-26 15:00:33 -07:00
snipe a5b1379cdb Removed skip mysql rule in tests 2026-05-26 22:52:03 +01:00
snipe ef64210ed2 Allow querying on cusotm fields fieldname directly by column name 2026-05-26 22:51:46 +01:00
snipe f29846ec20 Pint 2026-05-26 20:54:51 +01:00
snipe d2b4d84374 Updated translations 2026-05-26 20:51:26 +01:00
snipe dfe3f5fb9f Merge pull request #19045 from Godmartinz/gh-18990
adds 3rd pluralization to unaccepted_profile_warning
2026-05-26 19:49:22 +01:00
Godfrey M 8dbb19eb82 only change the en-US translation 2026-05-26 10:30:04 -07:00
snipe 45cdff6920 Merge pull request #19072 from grokability/security-fixes
Misc security fixes
2026-05-26 14:35:45 +01:00
snipe c25d56ea85 Refactored licenses controller to use a pessimistic lock inside a transaction 2026-05-26 14:24:02 +01:00
snipe f92a9a6cc6 Made isFullMultipleCompanySupportEnabled a public method 2026-05-26 14:23:32 +01:00
snipe 988729fbeb Skip user records if user exists in another company if FMCS is enabled 2026-05-26 13:36:02 +01:00
snipe e00f7b5b67 Added tests 2026-05-26 13:31:33 +01:00
snipe 39fbe98313 Fixed overwriting ownership of import 2026-05-26 13:11:30 +01:00
snipe 46d5234fd7 Throttle TOTP requests 2026-05-26 13:04:26 +01:00
snipe dd4117bd5b Tighter guard on user imports auth fields if the user is authenticated (aka not run via cli) 2026-05-26 12:56:10 +01:00
snipe 4dcd5190df Merge pull request #19025 from grokability/move-api-singletons-into-middleware
Move API singletons from SettingServiceProvider into middleware
2026-05-26 12:07:03 +01:00
snipe 48728e83b2 Merge pull request #19051 from grokability/_multi-company-support
Allow user to be a member of multiple companies
2026-05-26 12:03:24 +01:00
snipe 087b895bba Merge branch 'develop' into _multi-company-support
# Conflicts:
#	app/Http/Controllers/Users/BulkUsersController.php
#	app/Presenters/LicensePresenter.php
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-05-26 11:53:48 +01:00
snipe 2ed28f7f7a Dev manifest 2026-05-26 11:48:34 +01:00
snipe 9f50328da2 Merge hell :( 2026-05-26 11:48:15 +01:00
snipe 3737b34913 Back-patch security fixes 2026-05-26 11:36:29 +01:00
snipe aa0eb24e80 Fixed merge mess 2026-05-26 11:15:30 +01:00
snipe 9d012dd06d WTF 2026-05-26 11:14:27 +01:00
snipe df28c80553 Dev assets 2026-05-26 11:03:55 +01:00
snipe 2a3a3f7818 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 11:03:55 +01:00
snipe 15cb7993f6 Moved password visibility toggle to snipeit.js 2026-05-26 11:03:55 +01:00
snipe 15529a0c9c Bulk checkin license seats 2026-05-26 11:03:55 +01:00
snipe d2c30dd08c Dev assets 2026-05-26 11:03:55 +01:00
snipe 972b27140a Updated assets 2026-05-26 11:03:55 +01:00
snipe cac13dd949 Dev assets 2026-05-26 11:03:55 +01:00
snipe 112bf498e6 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 11:03:55 +01:00
snipe 02488a62c1 Updated controllers 2026-05-26 11:03:55 +01:00
snipe f5313f6ec0 Updated dev assets 2026-05-26 11:03:55 +01:00
snipe 3206549170 Moved password visibility toggle to snipeit.js 2026-05-26 11:03:48 +01:00
snipe 59b621500f Bulk checkin license seats 2026-05-26 11:03:40 +01:00
snipe cd5716d66d Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-26 11:03:07 +01:00
snipe 6a68a38d71 Dev assets 2026-05-26 11:02:44 +01:00
snipe f23ea5ce8f Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 11:02:35 +01:00
snipe c893b69b5f Fixed merge conflict 2026-05-26 10:52:04 +01:00
snipe 269e6c4ef6 Dev assets *again* 2026-05-26 10:49:32 +01:00
snipe a0ab9d3a80 Updated dev assets 2026-05-26 10:49:06 +01:00
snipe cdd72cf372 Dev assets 2026-05-26 10:49:05 +01:00
snipe e38b8cdd68 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 10:49:05 +01:00
snipe c44cb23dea Updated JS to add the array endpoint for company_ids (plural) 2026-05-26 10:49:05 +01:00
snipe 84bdfa98d1 Updated dev assets 2026-05-26 10:49:05 +01:00
snipe f3055e7442 Moved password visibility toggle to snipeit.js 2026-05-26 10:48:54 +01:00
snipe 9c36ade1e2 Bulk checkin license seats 2026-05-26 10:48:44 +01:00
snipe 4127c6a0c0 Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-26 10:48:24 +01:00
snipe c133c869ae Dev assets 2026-05-26 10:47:48 +01:00
snipe d74197aacc Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 10:47:40 +01:00
snipe c870dd0dae Updated assets 2026-05-26 10:41:48 +01:00
snipe 6d1d89105d Updated JS to add the array endpoint for company_ids (plural) 2026-05-26 10:41:30 +01:00
snipe f3a4f5edaa Allow query string or parameter for byserial 2026-05-26 10:41:23 +01:00
snipe 8f61d1e729 Add @CybotTM as a contributor 2026-05-26 10:41:23 +01:00
Sebastian Mendel 4782734ed4 Fix dead QUEUE_DRIVER env var name in templates and test config
`config/queue.php` reads `env('QUEUE_CONNECTION', 'sync')` since the
Laravel Shift in v6.0.0 (commit cc3c59bf97), but seven .env templates
and phpunit.xml still set `QUEUE_DRIVER` — the old Laravel <5.7 name
that the framework no longer reads. The default is `sync` anyway so
the gap is silent; but anyone copying these templates and trying to
enable an async driver (redis, database, beanstalkd, sqs) finds their
setting silently ignored.

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
2026-05-25 21:26:17 +02:00
snipe d099cbd8e5 Merge pull request #19067 from chrisnox/patch-1
Update README.md
2026-05-25 15:03:24 +01:00
chrisnox 33846b0d61 Update README.md 2026-05-25 01:35:25 +02:00
chrisnox b7df1dcefb Update README.md 2026-05-25 01:35:25 +02:00
Godfrey M f4b9138a3f forgot a comma 2026-05-22 10:41:30 -07:00
Godfrey M f5dbf27592 adds variations for lithuanian few and many translation choices 2026-05-22 10:39:52 -07:00
snipe e7c80b89eb Scope assets, locations, etc to the target, not the admin 2026-05-20 19:10:51 +01:00
snipe 50ba979840 Nicer formatting on user edit page when you cannot edit auth fields 2026-05-20 18:52:40 +01:00
snipe 6fd834e4d2 Tweaked light-label a little more 2026-05-20 18:48:17 +01:00
snipe 6ae09e15fb Updated tests and transformers 2026-05-20 16:17:02 +01:00
snipe f03b27ec88 Updated validator to accept single company_id or array 2026-05-20 15:08:53 +01:00
snipe cc1e0d82dd Tweaked label CSS 2026-05-20 15:08:13 +01:00
snipe f233bd2d01 New link formatter for BS tables 2026-05-20 15:07:11 +01:00
snipe 7a8b22df26 Updated users select2 to use new data-dash 2026-05-20 15:07:00 +01:00
snipe 17df4a08a7 Updated JS to add the array endpoint for company_ids (plural) 2026-05-20 15:04:02 +01:00
snipe c377b41198 Updated controllers 2026-05-20 14:58:18 +01:00
Godfrey M 0c59ca70cf adds 3rd pluralization to unaccepted_profile_warning 2026-05-19 15:35:14 -07:00
snipe e9e9dfeeab Load companies to avoid n+1 2026-05-19 14:40:39 +01:00
snipe f8c084cde7 This is hacky - might need to revisit 2026-05-19 14:40:20 +01:00
snipe 8f7fa6c0f5 Use new label for view-assets (not sure if I like this yet) 2026-05-19 14:39:53 +01:00
snipe f381362130 Tweaked company select for multiple if FMCS is enabled 2026-05-19 14:39:27 +01:00
snipe bef4a50720 Use multi-select in bulk user edit 2026-05-19 14:39:04 +01:00
snipe 2a93de675f Handle pipe delimited companyes in user importer 2026-05-19 14:38:52 +01:00
snipe e5f41f8f17 Use more common companies string 2026-05-19 14:38:28 +01:00
snipe b9da8ee55c Use multi-select for create user modal 2026-05-19 14:37:26 +01:00
snipe bf525f7213 Tweaked label + style for table labels (say that 100 times fast) 2026-05-19 14:37:08 +01:00
snipe c9ef163142 Added trans_choice option for company/companies 2026-05-19 14:36:36 +01:00
snipe feb3bd58cf Fixed wrong reference in fallback 2026-05-19 14:31:17 +01:00
snipe f9288e450b Update seeder 2026-05-19 14:24:24 +01:00
snipe 541128dd7a Updated tranformers 2026-05-19 14:23:44 +01:00
snipe 23b9c881ad Updated presenter 2026-05-19 14:16:45 +01:00
snipe cacd6f7e9b Add pipe separator to import more than one company for a user 2026-05-19 13:26:38 +01:00
snipe 4db4314f18 Added getCurrentUserCompanyIds (plural) to Company model 2026-05-19 13:26:06 +01:00
snipe 51aa66a77d Changed formatting just a bit 2026-05-19 13:24:49 +01:00
snipe aa0b491080 Added tests 2026-05-19 13:04:16 +01:00
snipe c01c9201ee Updated to use multiple select on users edit/create 2026-05-19 13:04:05 +01:00
snipe 0ad1a5b6ba Changed size of divs 2026-05-19 13:02:57 +01:00
snipe 95909d552a Show the list of companies if the infoPanelObj has more than one 2026-05-19 11:22:24 +01:00
snipe a159c3b84e Scary scary migration
We don’t actually drop the company_id field here, but later code will stop using it on the users table. This migration creates and populates the pivot table
2026-05-19 11:16:36 +01:00
snipe 19f70656ee Move API singletons from SettingServiceProvider into middleware 2026-05-13 22:20:46 +01:00
1465 changed files with 19638 additions and 3467 deletions
+9
View File
@@ -4271,6 +4271,15 @@
"contributions": [
"code"
]
},
{
"login": "CybotTM",
"name": "Sebastian Mendel",
"avatar_url": "https://avatars.githubusercontent.com/u/326348?v=4",
"profile": "https://github.com/CybotTM",
"contributions": [
"code"
]
}
]
}
+1 -1
View File
@@ -113,7 +113,7 @@ ENABLE_HSTS=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+2 -1
View File
@@ -37,6 +37,7 @@ MYSQL_ROOT_PASSWORD=changeme1234
DB_PREFIX=null
DB_DUMP_PATH='/usr/bin'
DB_DUMP_SKIP_SSL=true
DB_DUMP_SINGLE_TRANSACTION=false
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci
@@ -120,7 +121,7 @@ ENABLE_HSTS=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+1 -1
View File
@@ -72,7 +72,7 @@ CORS_ALLOWED_ORIGINS="*"
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
# --------------------------------------------
# OPTIONAL: LOGIN THROTTLING
+4 -2
View File
@@ -32,6 +32,7 @@ DB_PASSWORD=null
DB_PREFIX=null
DB_DUMP_PATH='/usr/bin'
DB_DUMP_SKIP_SSL=false
DB_DUMP_SINGLE_TRANSACTION=false
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci
DB_SANITIZE_BY_DEFAULT=false
@@ -133,7 +134,7 @@ BS_TABLE_DEEPLINK=true
APP_TRUSTED_PROXIES=192.168.1.1,10.0.0.1
ALLOW_IFRAMING=false
REFERRER_POLICY=same-origin
ENABLE_CSP=false
ENABLE_CSP=true
ADDITIONAL_CSP_URLS=null
CORS_ALLOWED_ORIGINS=null
ENABLE_HSTS=false
@@ -142,7 +143,7 @@ ENABLE_HSTS=false
# OPTIONAL: CACHE SETTINGS
# --------------------------------------------
CACHE_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
@@ -210,6 +211,7 @@ LOGIN_AUTOCOMPLETE=false
RESET_PASSWORD_LINK_EXPIRES=15
PASSWORD_CONFIRM_TIMEOUT=10800
PASSWORD_RESET_MAX_ATTEMPTS_PER_MIN=50
TWO_FACTOR_MAX_ATTEMPTS_PER_MIN=5
INVITE_PASSWORD_LINK_EXPIRES=1500
# --------------------------------------------
+1 -1
View File
@@ -69,7 +69,7 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") |
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") |
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [<img src="https://avatars.githubusercontent.com/u/2880129?v=4" width="110px;"/><br /><sub>Sebastian Marsching</sub>](http://sebastian.marsching.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [<img src="https://avatars.githubusercontent.com/u/40658372?v=4" width="110px;"/><br /><sub>Mo</sub>](https://github.com/mohammad-ahmadi1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") | [<img src="https://avatars.githubusercontent.com/u/326348?v=4" width="110px;"/><br /><sub>Sebastian Mendel</sub>](https://github.com/CybotTM)<br />[💻](https://github.com/snipe/snipe-it/commits?author=CybotTM "Code") |
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
+1
View File
@@ -56,6 +56,7 @@ COPY --from=mlocati/php-extension-installer:2.1.15 /usr/bin/install-php-extensio
RUN set -eux; \
install-php-extensions \
bcmath \
exif \
gd \
ldap \
mysqli \
+1
View File
@@ -98,6 +98,7 @@ Since the release of the JSON REST API, several third-party developers have been
- [InQRy (archived)](https://github.com/Microsoft/InQRy) by [@Microsoft](https://github.com/Microsoft)
- [Marksman (archived)](https://github.com/Scope-IT/marksman) - A Windows agent for Snipe-IT
- [Python Module (archived)](https://github.com/jbloomer/SnipeIT-PythonAPI) by [@jbloomer](https://github.com/jbloomer)
[IT-Tools](https://github.com/chrisnox/Snipeit-it-tools) by @chrisnox - Browser bookmarklets for PDF handover/return protocols, digital signatures, label printing (Zebra ZD410), AirWatch MDM sync and Lansweeper CSV import.
We also have a handful of [Google Apps scripts](https://github.com/grokability/google-apps-scripts-for-snipe-it) to help with various tasks.
+989
View File
@@ -0,0 +1,989 @@
<?php
namespace App\Console\Commands;
use App\Events\CheckoutableCheckedIn;
use App\Mail\BulkDeleteReportMail;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Console\Helper\ProgressBar;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\error;
use function Laravel\Prompts\info;
use function Laravel\Prompts\multisearch;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\search;
use function Laravel\Prompts\select;
use function Laravel\Prompts\warning;
class BulkDelete extends Command
{
protected $signature = 'snipeit:checkin-delete-items';
protected $description = 'Interactively check in and/or delete items by company and type';
private const CHECKIN_NOTE = 'Checked in via bulk CLI operation';
private array $reportLines = [];
public function handle(): int
{
// Step 1: Dry run?
$dryRun = confirm(
label: 'Is this a dry run?',
default: true,
yes: 'Yes — preview only, no changes will be made',
no: 'No — LIVE RUN, changes WILL be made',
);
// Step 2: Who are you?
$adminId = search(
label: 'Who are you? Search by username, first or last name.',
placeholder: 'Type to search users...',
options: function (string $value): array {
if (strlen($value) < 1) {
return [];
}
return User::where('activated', 1)
->whereNull('deleted_at')
->onlySuperAdmins()
->where(function ($query) use ($value) {
$query->where('username', 'like', "%{$value}%")
->orWhere('first_name', 'like', "%{$value}%")
->orWhere('last_name', 'like', "%{$value}%")
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", ["%{$value}%"]);
})
->get()
->mapWithKeys(fn (User $u) => [$u->id => "{$u->first_name} {$u->last_name} ({$u->username})"])
->toArray();
},
validate: fn (mixed $value) => ! $value ? 'A valid active user is required.' : null,
);
/** @var User $admin */
$admin = User::findOrFail((int) $adminId);
// Step 3: Which companies?
if (! Company::exists()) {
error('No companies found. Please create at least one company before using this command.');
return 1;
}
$selectedCompanyKeys = multisearch(
label: 'Which companies would you like to check in and delete items for?',
placeholder: 'Type to search companies...',
options: function (string $value): array {
$results = [];
if ($value === '' || str_contains('(no company / unassigned)', strtolower($value))) {
$results['__null__'] = '(No Company / Unassigned)';
}
$query = Company::orderBy('name');
if ($value !== '') {
$query->where('name', 'like', "%{$value}%");
}
$query->get()->each(function (Company $c) use (&$results) {
$results[$c->id] = "{$c->name} (ID: {$c->id})";
});
return $results;
},
scroll: 10,
required: 'Please select at least one company.',
hint: 'If you\'re searching on several differently named companies, use the up-arrow to go back to the search box to search again. ',
);
$includeNullCompany = in_array('__null__', $selectedCompanyKeys);
$selectedCompanyIds = array_values(array_filter(
$selectedCompanyKeys,
fn ($k) => $k !== '__null__'
));
$companyNamesById = Company::whereIn('id', $selectedCompanyIds)->pluck('name', 'id')->toArray();
$selectedCompanyNames = array_map(
fn ($id) => $id === '__null__' ? '(No Company)' : ($companyNamesById[$id] ?? "(ID: {$id})"),
$selectedCompanyKeys
);
// Step 4: Which item types?
$rawTypeSelection = multiselect(
label: 'What item types would you like to check in and delete?',
options: [
'all' => 'All Items (assets, licenses, accessories, components, consumables, users)',
'assets' => 'Assets',
'licenses' => 'Licenses',
'accessories' => 'Accessories',
'components' => 'Components',
'consumables' => 'Consumables',
'users' => 'Users',
],
required: 'Please select at least one item type.',
hint: 'Select "All Items" to process every supported type.',
);
$allSubTypes = ['assets', 'licenses', 'accessories', 'components', 'consumables', 'users'];
$selectedTypes = in_array('all', $rawTypeSelection)
? $allSubTypes
: array_values(array_intersect($allSubTypes, $rawTypeSelection));
// Compute and display counts now so the user can see what will be affected
$counts = $this->getCounts($selectedTypes, $selectedCompanyIds, $includeNullCompany);
$skipAdminUser = false;
$this->line('');
$this->line(' Items that would be affected:');
foreach ($counts as $type => $count) {
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
}
if (in_array('users', $selectedTypes)) {
$userInScope = $this->buildUserQuery($selectedCompanyIds, $includeNullCompany)
->where('users.id', $admin->id)
->exists();
if ($userInScope) {
$skipAdminUser = true;
$counts['users'] = max(0, ($counts['users'] ?? 0) - 1);
warning(" Your user ({$admin->username}) is within the selected scope and will be skipped during user deletion.");
}
}
$this->line('');
// Step 5: Hard delete, soft delete, or no delete?
$deleteType = select(
label: 'How should items be deleted?',
options: [
'soft' => 'Soft delete — items moved to trash (recoverable)',
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
'none' => 'No delete — check in only, items remain in inventory',
],
default: 'soft',
);
// Step 6: Send checkin notifications? (not applicable to users or consumables)
$notifiableTypes = array_intersect($selectedTypes, ['assets', 'licenses', 'accessories', 'components']);
$sendNotifications = false;
if (! empty($notifiableTypes)) {
$sendNotifications = confirm(
label: 'Should we send checkin notifications?',
default: true,
hint: 'Applies to: '.implode(', ', $notifiableTypes).'. Users and consumables are excluded.',
);
}
// Step 7: Clear related action_logs?
$clearLogs = confirm(
label: 'Should we clear related action logs?',
default: false,
hint: 'This removes all history for affected items, as if the data never existed.',
);
// Step 8: Delete associated files?
$deleteFiles = false;
if ($deleteType !== 'none') {
$deleteFiles = confirm(
label: 'Should we also delete associated image and upload files?',
default: $deleteType === 'hard',
hint: 'Permanently removes images, avatars, signatures, EULAs, and action log uploads from disk.',
);
}
// Step 9: Delete the companies themselves?
$deleteCompanyType = 'keep';
if (! empty($selectedCompanyIds)) {
$deleteCompanyType = select(
label: 'Should the selected companies also be deleted?',
options: [
'keep' => 'Keep — do not delete the companies',
'soft' => 'Soft delete — companies moved to trash (recoverable)',
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
],
default: 'keep',
);
}
// Step 10: Backup first?
$doBackup = confirm(
label: 'Should we run a backup before proceeding?',
default: true,
hint: 'Strongly recommended. Saved as backup-before-bulk-delete-cli-[datetime].zip',
);
// Step 11: Summary + final confirmation
$this->line('');
$this->line(' ════════════════════════════════════════════════════');
$this->line(' SUMMARY OF ACTIONS');
$this->line(' ════════════════════════════════════════════════════');
$this->line(" Admin user: {$admin->first_name} {$admin->last_name} ({$admin->username})");
$this->line(' Companies: '.implode(', ', $selectedCompanyNames));
$this->line(' Item types: '.implode(', ', $selectedTypes));
$this->line(" Delete mode: {$deleteType}");
$this->line(' Notifications: '.($sendNotifications ? 'Yes' : 'No'));
$this->line(' Clear logs: '.($clearLogs ? 'Yes' : 'No'));
$this->line(' Delete files: '.($deleteFiles ? 'Yes' : 'No'));
$this->line(' Delete companies: '.($deleteCompanyType === 'keep' ? 'No' : ucfirst($deleteCompanyType).' delete'));
$this->line(' Backup first: '.($doBackup ? 'Yes' : 'No'));
$this->line(' Dry run: '.($dryRun ? 'Yes' : 'No'));
$this->line('');
$this->line(' Items to be processed:');
foreach ($counts as $type => $count) {
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
}
if ($skipAdminUser) {
$this->line(' * Your user account will be skipped during user deletion.');
}
$this->line(' ════════════════════════════════════════════════════');
$this->line('');
// Step 10.5: Email report?
$sendEmailReport = false;
if ($admin->email) {
$sendEmailReport = confirm(
label: "Send an email report to {$admin->email}?",
default: false,
hint: 'A summary of all '.($dryRun ? 'would-be ' : '').'actions will be emailed to you.',
);
}
if (! $dryRun) {
$confirmed = confirm(
label: 'Are you sure you want to proceed? This cannot be undone.',
default: false,
);
if (! $confirmed) {
info('Aborted. No changes were made.');
return 0;
}
}
// Run backup if requested
if ($doBackup && ! $dryRun) {
$backupFilename = 'backup-before-bulk-delete-cli-'.now()->format('Y-m-d-H-i-s');
info("Running backup ({$backupFilename}.zip)...");
$result = $this->callSilently('snipeit:backup', ['--filename' => $backupFilename]);
if ($result === 0) {
info("Backup completed: {$backupFilename}.zip");
} else {
warning("Backup may have failed (exit code {$result}). Proceeding anyway.");
}
}
// Step 11: Execute with progress bar
$totalItems = array_sum($counts);
$bar = $this->output->createProgressBar($totalItems > 0 ? $totalItems : 1);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
$bar->setMessage('Starting...');
$bar->start();
foreach ($selectedTypes as $type) {
match ($type) {
'assets' => $this->processAssets($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'licenses' => $this->processLicenses($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'accessories' => $this->processAccessories($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'components' => $this->processComponents($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'consumables' => $this->processConsumables($selectedCompanyIds, $includeNullCompany, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'users' => $this->processUsers($selectedCompanyIds, $includeNullCompany, $admin, $skipAdminUser, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
};
}
$bar->setMessage('Done.');
$bar->finish();
$this->line('');
$this->line('');
// Delete companies if requested
if ($deleteCompanyType !== 'keep' && ! empty($selectedCompanyIds)) {
$companies = Company::whereIn('id', $selectedCompanyIds)->get();
foreach ($companies as $company) {
if ($dryRun) {
$this->line(" [dry-run] Would {$deleteCompanyType}-delete company {$company->name}");
$this->reportLines[] = "Would {$deleteCompanyType}-delete company {$company->name}";
} else {
if ($deleteCompanyType === 'soft') {
$company->delete();
} else {
$company->forceDelete();
}
// Remove any remaining pivot associations (e.g. the admin user who was
// skipped during user processing but is still a member of this company)
DB::table('company_user')->where('company_id', $company->id)->delete();
$this->reportLines[] = ucfirst($deleteCompanyType)."-deleted company {$company->name}";
}
}
}
if ($dryRun) {
warning('Dry run complete — no changes were made.');
} else {
info('All actions completed successfully.');
}
if ($sendEmailReport && $admin->email) {
Mail::to($admin->email)->send(new BulkDeleteReportMail(
admin: $admin,
dryRun: $dryRun,
companyNames: $selectedCompanyNames,
selectedTypes: $selectedTypes,
deleteType: $deleteType,
reportLines: $this->reportLines,
runAt: now(),
));
info("Report sent to {$admin->email}.");
}
return 0;
}
private function getCounts(array $types, array $companyIds, bool $includeNull): array
{
$counts = [];
if (in_array('assets', $types)) {
$counts['assets'] = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->count();
}
if (in_array('licenses', $types)) {
$counts['licenses'] = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->count();
}
if (in_array('accessories', $types)) {
$counts['accessories'] = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->count();
}
if (in_array('components', $types)) {
$counts['components'] = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->count();
}
if (in_array('consumables', $types)) {
$counts['consumables'] = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->count();
}
if (in_array('users', $types)) {
$counts['users'] = $this->buildUserQuery($companyIds, $includeNull)->count();
}
return $counts;
}
private function buildCompanyQuery(Builder $query, array $companyIds, bool $includeNull): Builder
{
return $query->where(function (Builder $q) use ($companyIds, $includeNull) {
if (! empty($companyIds)) {
$q->whereIn('company_id', $companyIds);
}
if ($includeNull) {
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
$q->{$method}('company_id');
}
});
}
private function buildUserQuery(array $companyIds, bool $includeNull): Builder
{
return User::query()
->where('activated', 1)
->where(function (Builder $q) use ($companyIds, $includeNull) {
if (! empty($companyIds)) {
$q->whereIn('company_id', $companyIds);
}
if ($includeNull) {
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
$q->{$method}('company_id');
}
});
}
private function processAssets(
array $companyIds,
bool $includeNull,
bool $sendNotifications,
User $admin,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$assets = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->get();
foreach ($assets as $asset) {
$bar->setMessage("Assets: {$asset->asset_tag}");
if ($asset->assignedTo) {
if ($dryRun) {
$this->line(" [dry-run] Would check in asset {$asset->asset_tag} from {$asset->assignedTo->name}");
$this->reportLines[] = "Would check in asset {$asset->asset_tag} (assigned to {$asset->assignedTo->name})";
} else {
$target = $asset->assignedTo;
$checkinAt = now()->format('Y-m-d H:i:s');
$originalValues = $asset->getRawOriginal();
if ($sendNotifications) {
event(new CheckoutableCheckedIn($asset, $target, $admin, self::CHECKIN_NOTE, $checkinAt, $originalValues));
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
} else {
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
$asset->logCheckin($target, self::CHECKIN_NOTE, $checkinAt, $originalValues);
}
$this->reportLines[] = "Checked in asset {$asset->asset_tag} from {$target->name}";
$asset->licenseseats()->update(['assigned_to' => null]);
CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->whereNull('accepted_at')
->whereNull('declined_at')
->forceDelete();
}
}
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $asset->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
// Delete checkout acceptance files, then hard-remove all acceptances
if ($deleteFiles) {
CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->get()
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
}
CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->forceDelete();
// Hard-delete-only cleanup: maintenance records, accessory checkouts to this
// asset, and any other assets that were assigned to this one
$maintenanceImages = [];
if ($deleteType === 'hard') {
if ($deleteFiles) {
$maintenanceImages = $asset->maintenances()
->whereNotNull('image')
->pluck('image')
->toArray();
}
$asset->maintenances()->forceDelete();
AccessoryCheckout::where('assigned_to', $asset->id)
->where('assigned_type', Asset::class)
->delete();
DB::table('assets')
->where('assigned_to', $asset->id)
->where('assigned_type', Asset::class)
->update(['assigned_to' => null, 'assigned_type' => null]);
}
match ($deleteType) {
'soft' => $asset->delete(),
'hard' => $asset->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted asset {$asset->asset_tag}";
}
if ($clearLogs) {
$asset->assetlog()->forceDelete();
}
if ($deleteFiles) {
if ($asset->image) {
$this->deleteStorageFile('public', app('assets_upload_path').$asset->image);
}
foreach ($maintenanceImages as $img) {
$this->deleteStorageFile('public', app('maintenances_upload_path').$img);
}
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete asset {$asset->asset_tag}");
$this->reportLines[] = "Would {$deleteType}-delete asset {$asset->asset_tag}";
}
$bar->advance();
}
}
private function processLicenses(
array $companyIds,
bool $includeNull,
bool $sendNotifications,
User $admin,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$licenses = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->get();
foreach ($licenses as $license) {
$bar->setMessage("Licenses: {$license->name}");
$seats = LicenseSeat::where('license_id', $license->id)
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
->get();
foreach ($seats as $seat) {
$target = $seat->assigned_to ? $seat->user : $seat->asset;
if ($dryRun) {
$this->line(" [dry-run] Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown'));
$this->reportLines[] = "Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
} else {
$seat->assigned_to = null;
$seat->asset_id = null;
$seat->save();
$this->reportLines[] = "Checked in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
if ($target) {
if ($sendNotifications) {
event(new CheckoutableCheckedIn($seat, $target, $admin, self::CHECKIN_NOTE));
} else {
$seat->logCheckin($target, self::CHECKIN_NOTE);
}
}
}
}
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $license->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
if ($deleteType === 'soft') {
$license->licenseseats()->delete();
$license->delete();
$this->reportLines[] = "Soft-deleted license {$license->name}";
} elseif ($deleteType === 'hard') {
$seatIds = $license->licenseseats()->pluck('id');
if ($deleteFiles) {
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
->whereIn('checkoutable_id', $seatIds)
->get()
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
}
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
->whereIn('checkoutable_id', $seatIds)
->forceDelete();
$license->licenseseats()->forceDelete();
DB::table('kits_licenses')->where('license_id', $license->id)->delete();
$license->forceDelete();
$this->reportLines[] = "Hard-deleted license {$license->name}";
}
if ($clearLogs) {
$license->assetlog()->forceDelete();
}
if ($deleteFiles) {
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete license {$license->name}");
$this->reportLines[] = "Would {$deleteType}-delete license {$license->name}";
}
$bar->advance();
}
}
private function processAccessories(
array $companyIds,
bool $includeNull,
bool $sendNotifications,
User $admin,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$accessories = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->get();
foreach ($accessories as $accessory) {
$bar->setMessage("Accessories: {$accessory->name}");
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
foreach ($checkouts as $checkout) {
$target = $checkout->assignedTo;
if ($dryRun) {
$this->line(" [dry-run] Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown'));
$this->reportLines[] = "Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
} else {
$checkinAt = now()->format('Y-m-d H:i:s');
$checkout->delete();
$this->reportLines[] = "Checked in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
if ($target) {
if ($sendNotifications) {
event(new CheckoutableCheckedIn($accessory, $target, $admin, self::CHECKIN_NOTE, $checkinAt));
} else {
$accessory->logCheckin($target, self::CHECKIN_NOTE, $checkinAt);
}
}
}
}
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $accessory->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
if ($clearLogs) {
$accessory->assetlog()->forceDelete();
}
if ($deleteType === 'hard') {
DB::table('kits_accessories')->where('accessory_id', $accessory->id)->delete();
}
match ($deleteType) {
'soft' => $accessory->delete(),
'hard' => $accessory->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted accessory {$accessory->name}";
}
if ($deleteFiles) {
if ($accessory->image) {
$this->deleteStorageFile('public', app('accessories_upload_path').$accessory->image);
}
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete accessory {$accessory->name}");
$this->reportLines[] = "Would {$deleteType}-delete accessory {$accessory->name}";
}
$bar->advance();
}
}
private function processComponents(
array $companyIds,
bool $includeNull,
bool $sendNotifications,
User $admin,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$components = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->get();
foreach ($components as $component) {
$bar->setMessage("Components: {$component->name}");
$assignments = DB::table('components_assets')
->where('component_id', $component->id)
->get();
foreach ($assignments as $assignment) {
$asset = Asset::find($assignment->asset_id);
if ($dryRun) {
$this->line(" [dry-run] Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown'));
$this->reportLines[] = "Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
} else {
$checkinAt = now()->format('Y-m-d H:i:s');
DB::table('components_assets')->where('id', $assignment->id)->delete();
$this->reportLines[] = "Checked in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
if ($asset) {
if ($sendNotifications) {
event(new CheckoutableCheckedIn($component, $asset, $admin, self::CHECKIN_NOTE, $checkinAt));
} else {
$component->logCheckin($asset, self::CHECKIN_NOTE, $checkinAt);
}
}
}
}
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $component->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
if ($clearLogs) {
$component->assetlog()->forceDelete();
}
match ($deleteType) {
'soft' => $component->delete(),
'hard' => $component->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted component {$component->name}";
}
if ($deleteFiles) {
if ($component->image) {
$this->deleteStorageFile('public', app('components_upload_path').$component->image);
}
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete component {$component->name}");
$this->reportLines[] = "Would {$deleteType}-delete component {$component->name}";
}
$bar->advance();
}
}
private function processConsumables(
array $companyIds,
bool $includeNull,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$consumables = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->get();
foreach ($consumables as $consumable) {
$bar->setMessage("Consumables: {$consumable->name}");
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $consumable->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
if ($clearLogs) {
$consumable->assetlog()->forceDelete();
}
if ($deleteType === 'hard') {
DB::table('kits_consumables')->where('consumable_id', $consumable->id)->delete();
}
match ($deleteType) {
'soft' => $consumable->delete(),
'hard' => $consumable->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted consumable {$consumable->name}";
}
if ($deleteFiles) {
if ($consumable->image) {
$this->deleteStorageFile('public', app('consumables_upload_path').$consumable->image);
}
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete consumable {$consumable->name}");
$this->reportLines[] = "Would {$deleteType}-delete consumable {$consumable->name}";
}
$bar->advance();
}
}
private function processUsers(
array $companyIds,
bool $includeNull,
User $admin,
bool $skipAdminUser,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$users = $this->buildUserQuery($companyIds, $includeNull)->get();
foreach ($users as $user) {
if ($skipAdminUser && $user->id === $admin->id) {
continue;
}
$bar->setMessage("Users: {$user->username}");
// If real companies were selected, check whether this user also belongs to
// companies outside the selected scope. If so, only remove the selected-company
// associations and skip full deletion to avoid orphaning them from their other companies.
if (! empty($companyIds)) {
$allUserCompanyIds = array_unique(array_filter(array_merge(
$user->companies()->pluck('companies.id')->toArray(),
$user->company_id ? [$user->company_id] : [],
)));
$outsideCompanyIds = array_values(array_diff($allUserCompanyIds, $companyIds));
if (! empty($outsideCompanyIds)) {
$outsideNames = Company::whereIn('id', $outsideCompanyIds)->pluck('name')->implode(', ');
if ($dryRun) {
$this->line(" [dry-run] Would partially disassociate user {$user->username} (also belongs to: {$outsideNames})");
$this->reportLines[] = "Would partially disassociate user {$user->username} — also belongs to: {$outsideNames}";
} else {
$user->companies()->detach($companyIds);
warning(" Skipped full deletion of {$user->username}: they also belong to {$outsideNames}. Removed selected company associations only.");
$this->reportLines[] = "Partially disassociated user {$user->username} — also belongs to: {$outsideNames}. Full deletion skipped.";
}
$bar->advance();
continue;
}
}
if (! $dryRun) {
// Collect file paths and acceptance records before deleting pivot data
$acceptancesToDelete = $deleteFiles
? CheckoutAcceptance::where('assigned_to_id', $user->id)->get()
: collect();
$actionLogPaths = $deleteFiles
? Actionlog::where('item_type', User::class)
->where('item_id', $user->id)
->where('action_type', 'uploaded')
->whereNotNull('filename')
->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
// Clear pivot/assignment data that will orphan on deletion
LicenseSeat::where('assigned_to', $user->id)->update(['assigned_to' => null]);
AccessoryCheckout::where('assigned_to', $user->id)
->where('assigned_type', User::class)
->delete();
DB::table('consumables_users')->where('assigned_to', $user->id)->delete();
CheckoutAcceptance::where('assigned_to_id', $user->id)->forceDelete();
if ($deleteType === 'hard') {
DB::table('company_user')->where('user_id', $user->id)->delete();
}
if ($clearLogs) {
$user->userlog()->forceDelete();
}
match ($deleteType) {
'soft' => $user->delete(),
'hard' => $user->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted user {$user->username}";
}
if ($deleteFiles) {
if ($user->avatar) {
$this->deleteStorageFile('public', app('users_upload_path').$user->avatar);
}
$acceptancesToDelete->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete user {$user->username}");
$this->reportLines[] = "Would {$deleteType}-delete user {$user->username}";
}
$bar->advance();
}
}
private function deleteStorageFile(string $disk, ?string $path): void
{
if (! $path) {
return;
}
try {
$storage = $disk === 'public'
? Storage::disk('public')
: Storage::disk(config('filesystems.default'));
if ($storage->exists($path)) {
$storage->delete($path);
}
} catch (\Exception $e) {
Log::warning("Could not delete file {$path}: ".$e->getMessage());
}
}
private function deleteAcceptanceFiles(CheckoutAcceptance $acceptance): void
{
if ($acceptance->signature_filename) {
$this->deleteStorageFile('local', 'private_uploads/signatures/'.$acceptance->signature_filename);
}
if ($acceptance->stored_eula_file) {
$this->deleteStorageFile('local', 'private_uploads/eula-pdfs/'.$acceptance->stored_eula_file);
}
}
}
+8
View File
@@ -19,6 +19,7 @@ use Illuminate\Validation\ValidationException;
use Intervention\Image\Exception\NotSupportedException;
use JsonException;
use League\OAuth2\Server\Exception\OAuthServerException;
use Livewire\Exceptions\ComponentNotFoundException;
use Livewire\Exceptions\PublicPropertyNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
@@ -43,6 +44,7 @@ class Handler extends ExceptionHandler
SCIMException::class, // these generally don't need to be reported
InvalidFormatException::class,
PublicPropertyNotFoundException::class,
ComponentNotFoundException::class,
];
/**
@@ -78,6 +80,12 @@ class Handler extends ExceptionHandler
return response()->json(['message' => $e->getMessage()], 422);
}
// A request named a Livewire component that doesn't exist in this app (e.g. bots probing
// for Filament endpoints). Return 404 so it doesn't surface as a 500.
if ($e instanceof ComponentNotFoundException) {
return response()->json(['message' => 'Component not found.'], 404);
}
// CSRF token mismatch error
if ($e instanceof TokenMismatchException) {
return redirect()->back()->with('error', trans('general.token_expired'));
+87 -9
View File
@@ -14,6 +14,7 @@ use App\Models\License;
use App\Models\Location;
use App\Models\Setting;
use App\Models\Statuslabel;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\RedirectResponse;
@@ -663,7 +664,7 @@ class Helper
*/
public static function depreciationList()
{
$depreciation_list = ['' => 'Do Not Depreciate'] + Depreciation::orderBy('name', 'asc')
$depreciation_list = ['' => trans('admin/licenses/form.no_depreciation')] + Depreciation::orderBy('name', 'asc')
->pluck('name', 'id')->toArray();
return $depreciation_list;
@@ -1268,6 +1269,7 @@ class Helper
$allowedExtensionMap = [
// Images
'jpg' => 'far fa-image',
'jfif' => 'far fa-image',
'jpeg' => 'far fa-image',
'gif' => 'far fa-image',
'png' => 'far fa-image',
@@ -1596,7 +1598,17 @@ class Helper
$checkout_to_type = session('checkout_to_type') ?? null;
$checkedInFrom = session('checkedInFrom');
$other_redirect = session('other_redirect');
$backUrl = session()->pull('url.intended', 'home');
$backUrl = str_replace(["\r", "\n"], '', session()->pull('url.intended', 'home'));
// Reject any stored back-URL that points off-site. redirect()->intended() performs
// no host validation, and url.intended can be written from the SAML RelayState POST
// parameter (SamlController), which an attacker-controlled IdP could set to an
// off-site URL.
$backHost = parse_url($backUrl, PHP_URL_HOST);
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
if ($backHost && $backHost !== $appHost) {
$backUrl = route('home');
}
// return to previous page
if ($redirect_option == 'back') {
@@ -1689,6 +1701,8 @@ class Helper
return [];
}
$floater = (bool) Setting::getSettings()->null_company_is_floater;
foreach ($locations as $location) {
// in case of an update of a single location, use the newly requested company_id
if ($new_company_id) {
@@ -1723,26 +1737,51 @@ class Helper
foreach ($keywords as $keyword) {
if ($relation == 'many') {
$items = $location->{$keyword}->all();
// assignedAccessories returns AccessoryCheckout records (no company_id);
// resolve each to its parent Accessory so the comparison is valid.
if ($keyword === 'assignedAccessories') {
$items = collect($items)->map(fn ($checkout) => $checkout->accessory)->filter()->values()->all();
}
} else {
$items = collect([])->push($location->$keyword);
}
$count = 0;
foreach ($items as $item) {
if (! $item) {
continue;
}
if ($item && $item->company_id != $location_company) {
// Users belong to companies via the many-to-many pivot (company_user).
// canReceiveFromCompany() returns true only when the user's pivot
// contains the location's company, so !canReceiveFromCompany() is
// the correct mismatch signal.
if ($item instanceof User) {
$isMismatch = ! $item->canReceiveFromCompany((int) $location_company);
} elseif ($item->company_id == $location_company) {
$isMismatch = false;
} elseif (is_null($item->company_id) || is_null($location_company)) {
$isMismatch = ! $floater;
} else {
$isMismatch = true;
}
if ($isMismatch) {
if ($item instanceof User) {
$itemCompanyIds = $item->companies->pluck('id')->implode(', ');
$itemCompanyNames = $item->companies->pluck('name')->implode(', ');
} else {
$itemCompanyIds = $item->company_id ?? null;
$itemCompanyNames = $item->company->name ?? null;
}
$mismatched[] = [
class_basename(get_class($item)),
$item->id,
$item->name ?? $item->asset_tag ?? $item->serial ?? $item->username,
$item->assigned_type ? str_replace('App\\Models\\', '', $item->assigned_type) : null,
$item->company_id ?? null,
$item->company->name ?? null,
// $item->defaultLoc->id ?? null,
// $item->defaultLoc->name ?? null,
// $item->defaultLoc->company->id ?? null,
// $item->defaultLoc->company->name ?? null,
$itemCompanyIds,
$itemCompanyNames,
$item->location->name ?? null,
$item->location->company->name ?? null,
$location_company ?? null,
@@ -1856,4 +1895,43 @@ class Helper
return 'App\\Models\\'.ucwords($model);
}
/**
* Render a markdown-textarea value as HTML.
*
* Soft line breaks (single newlines) are rendered as <br> so that line
* breaks typed in the textarea are preserved in the output.
*
* When $inline is true, block-level elements are suppressed and hard
* breaks are pre-processed manually — used for the encrypted reveal span
* where block HTML cannot be placed inside a font-size-toggled <span>.
*/
public static function renderMarkdown(?string $text, bool $inline = false): string
{
if (empty($text)) {
return '';
}
if ($inline) {
// Convert newlines to CommonMark hard breaks for inline rendering
$text = preg_replace('/(?<! {2})\n/', " \n", $text);
return Str::inlineMarkdown($text, ['html_input' => 'escape', 'allow_unsafe_links' => false]);
}
$html = trim(Str::markdown($text, [
'html_input' => 'escape',
'allow_unsafe_links' => false,
'renderer' => ['soft_break' => "<br>\n"],
]));
// If the entire output is a single <p> block, unwrap it so the content
// renders inline-ish without the <p> adding unwanted top spacing in the
// compact detail-view layout.
if (str_starts_with($html, '<p>') && str_ends_with($html, '</p>') && substr_count($html, '<p>') === 1) {
return substr($html, 3, -4);
}
return $html;
}
}
@@ -66,6 +66,20 @@ class AccessoryCheckoutController extends Controller
$target = $this->determineCheckoutTarget();
session()->put(['checkout_to_type' => $target]);
if (! $accessory->canCheckoutTo($target)) {
$targetType = match (class_basename($target)) {
'User' => trans('general.user'),
'Location' => trans('general.location'),
default => trans('general.asset'),
};
return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
'item' => trans('general.accessory').' "'.$accessory->name.'"',
'item_company' => $accessory->company?->name ?? trans('general.unassigned'),
'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
]));
}
$accessory->checkout_qty = $request->input('checkout_qty', 1);
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
@@ -107,7 +107,7 @@ class AccessoriesController extends Controller
}
// Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $accessories->count()) ? $accessories->count() : abs($request->input('offset'));
$offset = ($request->input('offset') > $accessories->count()) ? $accessories->count() : app('api_offset_value');
$limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
@@ -234,6 +234,10 @@ class AccessoriesController extends Controller
$total = $accessory_checkouts->count();
$accessory_checkouts = $accessory_checkouts->skip($offset)->take($limit)->get();
$accessory_checkouts->loadMorph('assignedTo', [
User::class => ['companies'],
]);
return (new AccessoriesTransformer)->transformCheckedoutAccessory($accessory_checkouts, $total);
}
@@ -303,7 +307,7 @@ class AccessoriesController extends Controller
$this->authorize('checkout', $accessory);
$target = $this->determineCheckoutTarget();
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($accessory->company_id !== $target->company_id)) {
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $target->companies()->where('companies.id', $accessory->company_id)->exists())) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
@@ -133,7 +133,8 @@ class AssetModelsController extends Controller
}
// Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $assetmodels->count()) ? $assetmodels->count() : abs($request->input('offset'));
$total = $assetmodels->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
@@ -157,7 +158,6 @@ class AssetModelsController extends Controller
break;
}
$total = $assetmodels->count();
$assetmodels = $assetmodels->skip($offset)->take($limit)->get();
return (new AssetModelsTransformer)->transformAssetModels($assetmodels, $total);
+30 -15
View File
@@ -371,6 +371,12 @@ class AssetsController extends Controller
$assets->where('assets.order_number', '=', strval($request->input('order_number')));
}
foreach ($all_custom_fields as $field) {
if ($request->filled($field->db_column_name())) {
$assets->where($field->db_column_name(), '=', $request->input($field->db_column_name()));
}
}
// This is kinda gross, but we need to do this because the Bootstrap Tables
// API passes custom field ordering as custom_fields.fieldname, and we have to strip
// that out to let the default sorter below order them correctly on the assets table.
@@ -603,8 +609,20 @@ class AssetsController extends Controller
])->with('model', 'status', 'assignedTo')
->NotArchived();
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$assets->where('assets.company_id', $request->input('companyId'));
// When FMCS is enabled, automatically scope to companies the acting user belongs to.
// scopeCompanyables is a no-op for superusers and when FMCS is disabled.
$assets = Company::scopeCompanyables($assets);
// Allow further narrowing to a specific company passed via data-company-id on the select.
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
if (! empty($companyIds)) {
$assets->whereIn('assets.company_id', $companyIds);
}
}
if ($request->filled('excludeId')) {
$assets->where('assets.id', '!=', (int) $request->input('excludeId'));
}
if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
@@ -895,11 +913,7 @@ class AssetsController extends Controller
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
{
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($target->company_id))
&& ((int) $asset->company_id !== (int) $target->company_id)
) {
if (! $asset->canCheckoutTo($target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
@@ -1053,13 +1067,8 @@ class AssetsController extends Controller
}
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
$targetCompanyId = data_get($target, 'company_id');
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($targetCompanyId))
&& ((int) $asset->company_id !== (int) $targetCompanyId)
) {
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, trans('general.error_user_company')));
if ($mismatch = $this->checkoutCompanyMismatchResponse($asset, $target)) {
return $mismatch;
}
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
@@ -1117,7 +1126,9 @@ class AssetsController extends Controller
$asset->assignedTo()->disassociate($asset);
$asset->accepted = null;
if ($request->has('name')) {
if ($request->input('clear_name') == '1') {
$asset->name = null;
} elseif ($request->has('name')) {
$asset->name = $request->input('name');
}
@@ -1260,6 +1271,10 @@ class AssetsController extends Controller
$asset->last_audit_date = date('Y-m-d H:i:s');
if ($request->input('clear_name') == '1') {
$asset->name = null;
}
// Set up the payload for re-display in the API response
$payload = [
'id' => $asset->id,
@@ -9,6 +9,7 @@ use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\CompaniesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@@ -206,6 +207,16 @@ class CompaniesController extends Controller
'companies.tag_color',
]);
// When FMCS is enabled and the user is not a superuser, restrict the list to
// companies they belong to (primary company_id + pivot companies). This lets
// non-superusers select a company from their own set when creating assets, etc.
if (Setting::getSettings()->full_multiple_companies_support == '1' && ! auth()->user()->isSuperUser()) {
$userCompanyIds = auth()->user()->allCompanies()->pluck('id');
if ($userCompanyIds->isNotEmpty()) {
$companies->whereIn('companies.id', $userCompanyIds);
}
}
if ($request->filled('search')) {
$companies = $companies->where('companies.name', 'LIKE', '%'.$request->input('search').'%');
}
@@ -315,7 +315,7 @@ class ConsumablesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($consumable->company_id !== $user->company_id)) {
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $user->companies()->where('companies.id', $consumable->company_id)->exists())) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
@@ -27,7 +27,7 @@ class LicenseSeatsController extends Controller
if ($license = License::find($licenseId)) {
$this->authorize('view', $license);
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department', 'user.company', 'asset.company')
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department', 'user.companies', 'asset.company')
->where('license_seats.license_id', $licenseId);
if ($request->input('status') == 'available') {
@@ -132,91 +132,110 @@ class LicenseSeatsController extends Controller
$this->authorize('checkout', License::class);
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])->find($seatId);
$errorResponse = null;
$updatedSeat = null;
if (! $licenseSeat) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
}
// Fetch the seat with a pessimistic lock inside a transaction so concurrent requests
// on the same seat serialise rather than racing to overwrite each other's assignment.
DB::transaction(function () use ($request, $licenseId, $seatId, $validated, &$errorResponse, &$updatedSeat): void {
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])
->lockForUpdate()
->find($seatId);
$license = $licenseSeat->license;
if (! $license || $license->id != intval($licenseId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
}
if (! $licenseSeat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetUser->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
$license = $licenseSeat->license;
if (! $license || $license->id != intval($licenseId)) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
if (! $targetAsset) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $targetUser->companies()->where('companies.id', $license->company_id)->exists())) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
// attempt to update the license seat
$licenseSeat->fill($validated);
if (! $targetAsset) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
// check if this update is a checkin operation
// 1. are relevant fields touched at all?
$assignmentTouched = $licenseSeat->isDirty('assigned_to') || $licenseSeat->isDirty('asset_id');
$anythingTouched = $licenseSeat->isDirty();
return;
}
if (! $anythingTouched) {
return response()->json(
Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))
);
}
if ($assignmentTouched && $licenseSeat->unreassignable_seat) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
// 2. are they cleared? if yes then this is a checkin operation
$is_checkin = ($assignmentTouched && $licenseSeat->assigned_to === null && $licenseSeat->asset_id === null);
$target = null;
// the logging functions expect only one "target". if both asset and user are present in the request,
// we simply let assets take precedence over users...
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
// if both asset_id and assigned_to are null then we are "checking-in"
// a related model that does not exist (possible purged or bad data).
if (! is_null($request->input('asset_id')) || ! is_null($request->input('assigned_to'))) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
$licenseSeat->fill($validated);
$assignmentTouched = $licenseSeat->isDirty('assigned_to') || $licenseSeat->isDirty('asset_id');
$anythingTouched = $licenseSeat->isDirty();
if (! $anythingTouched) {
$updatedSeat = $licenseSeat;
return;
}
if ($assignmentTouched && $licenseSeat->unreassignable_seat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
return;
}
// Are the assignment fields cleared? If yes, this is a checkin operation.
$is_checkin = ($assignmentTouched && $licenseSeat->assigned_to === null && $licenseSeat->asset_id === null);
// The logging functions expect only one "target"; assets take precedence over users.
$target = null;
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
// Both fields are null but one was provided — the related model is purged or bad data.
if (! is_null($request->input('asset_id')) || ! is_null($request->input('assigned_to'))) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
}
}
// Keep seat updates and checkout/checkin logging atomic to prevent partial state changes.
$updated = DB::transaction(function () use ($licenseSeat, $assignmentTouched, $is_checkin, $target, $request): bool {
if (! $licenseSeat->save()) {
return false;
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
if ($assignmentTouched) {
@@ -225,25 +244,29 @@ class LicenseSeatsController extends Controller
$licenseSeat->unreassignable_seat = true;
if (! $licenseSeat->save()) {
return false;
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
}
// todo: skip if target is null?
$licenseSeat->logCheckin($target, $licenseSeat->notes);
} else {
// in this case, relevant fields are touched but it's not a checkin operation. so it must be a checkout operation.
$licenseSeat->logCheckout($request->input('notes'), $target);
}
}
return true;
$updatedSeat = $licenseSeat;
});
if ($updated) {
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
if ($errorResponse) {
return $errorResponse;
}
return Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors());
if ($updatedSeat) {
return response()->json(Helper::formatStandardApiResponse('success', $updatedSeat, trans('admin/licenses/message.update.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'An unexpected error occurred'), 500);
}
}
@@ -2,15 +2,21 @@
namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedIn;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\LicenseSeatsTransformer;
use App\Http\Transformers\LicensesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -261,6 +267,167 @@ class LicensesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.assoc_users')));
}
/**
* Checkout a license seat to a user or asset.
*
* Accepts an optional `seat_id`; if omitted the next available free seat is used.
* `target_type` must be "user" or "asset". Supply `assigned_to` for users or
* `asset_id` for assets.
*
* This will eventually use the same form request the UI uses, but we need to update the field names first.
*
* @param int $licenseId
*/
public function checkout(Request $request, $licenseId): JsonResponse
{
$license = License::findOrFail($licenseId);
$this->authorize('checkout', $license);
$validated = $this->validate($request, [
'seat_id' => 'sometimes|integer|nullable',
'target_type' => 'required|in:user,asset',
'assigned_to' => 'required_if:target_type,user|integer|nullable',
'asset_id' => 'required_if:target_type,asset|integer|nullable',
'notes' => 'sometimes|string|nullable',
]);
if ($license->isInactive()) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.license_is_inactive')));
}
$errorResponse = null;
$updatedSeat = null;
$target = null;
DB::transaction(function () use ($license, $validated, &$errorResponse, &$updatedSeat, &$target): void {
$seatId = $validated['seat_id'] ?? null;
$licenseSeat = $seatId
? LicenseSeat::where('id', $seatId)->where('license_id', $license->id)->lockForUpdate()->first()
: $license->freeSeat(lock: true);
if (! $licenseSeat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.not_enough_seats')));
return;
}
if ($licenseSeat->unreassignable_seat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
return;
}
if ($validated['target_type'] === 'user') {
$target = User::withoutGlobalScopes()->whereNull('deleted_at')->find($validated['assigned_to'] ?? null);
if (! $target) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.user_does_not_exist')));
return;
}
if (Company::isFullMultipleCompanySupportEnabled() && ! $target->companies()->where('companies.id', $license->company_id)->exists()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
$licenseSeat->assigned_to = $target->id;
$licenseSeat->asset_id = null;
} else {
$target = Asset::withoutGlobalScopes()->whereNull('deleted_at')->find($validated['asset_id'] ?? null);
if (! $target) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.asset_does_not_exist')));
return;
}
if (Company::isFullMultipleCompanySupportEnabled() && $license->company_id && $license->company_id !== $target->company_id) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
$licenseSeat->asset_id = $target->id;
$licenseSeat->assigned_to = null;
if ($target->checkedOutToUser()) {
$licenseSeat->assigned_to = $target->assigned_to;
}
}
$licenseSeat->notes = $validated['notes'] ?? null;
$licenseSeat->created_by = auth()->id();
if (! $licenseSeat->save()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), $validated['notes'] ?? null));
$updatedSeat = $licenseSeat->load('license', 'user', 'asset');
});
if ($errorResponse) {
return $errorResponse;
}
if ($updatedSeat) {
return response()->json(Helper::formatStandardApiResponse('success', (new LicenseSeatsTransformer)->transformLicenseSeat($updatedSeat), trans('admin/licenses/message.checkout.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'An unexpected error occurred'), 500);
}
/**
* Checkin a license seat.
*
* `seat_id` is required to identify which seat to check back in.
*
* @param int $licenseId
*/
public function checkin(Request $request, $licenseId): JsonResponse
{
$license = License::findOrFail($licenseId);
$this->authorize('checkin', $license);
$validated = $this->validate($request, [
'seat_id' => 'required|integer',
'notes' => 'sometimes|string|nullable',
]);
$licenseSeat = LicenseSeat::where('id', $validated['seat_id'])
->where('license_id', $license->id)
->first();
if (! $licenseSeat) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.not_found')));
}
if (is_null($licenseSeat->assigned_to) && is_null($licenseSeat->asset_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkin.error')));
}
$target = $licenseSeat->user ?? $licenseSeat->asset;
$licenseSeat->assigned_to = null;
$licenseSeat->asset_id = null;
$licenseSeat->notes = $validated['notes'] ?? null;
if (! $license->reassignable) {
$licenseSeat->unreassignable_seat = true;
}
if (! $licenseSeat->save()) {
return response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
}
event(new CheckoutableCheckedIn($licenseSeat, $target, auth()->user(), $licenseSeat->notes));
return response()->json(Helper::formatStandardApiResponse('success', (new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat->load('license', 'user', 'asset')), trans('admin/licenses/message.checkin.success')));
}
/**
* Gets a paginated collection for the select2 menus
*
@@ -67,7 +67,18 @@ class LocationsController extends Controller
'notes',
];
$locations = Location::with('parent', 'manager', 'children')->select([
$locations = Location::with([
'parent',
'children',
'manager' => fn ($q) => $q->withCount([
'assets as assets_count',
'accessories as accessories_count',
'licenses as licenses_count',
'consumables as consumables_count',
'managesUsers as manages_users_count',
'managedLocations as manages_locations_count',
]),
])->select([
'locations.id',
'locations.name',
'locations.address',
@@ -103,7 +114,9 @@ class LocationsController extends Controller
->withCount('components as components_count')
->with('adminuser');
// Only scope locations if the setting is enabled
// scope_locations_fmcs is required for location-level company scoping (locations may not
// have company_id assigned unless the compatibility check has been completed in Settings).
// Without it, locations are visible to all authenticated users regardless of FMCS state.
if (Setting::getSettings()->scope_locations_fmcs) {
$locations = Company::scopeCompanyables($locations);
}
@@ -157,8 +170,6 @@ class LocationsController extends Controller
$locations->where('tag_color', '=', $request->input('locations.tag_color'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $locations->count()) ? $locations->count() : app('api_offset_value');
$limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
@@ -180,6 +191,7 @@ class LocationsController extends Controller
}
$total = $locations->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$locations = $locations->skip($offset)->take($limit)->get();
return (new LocationsTransformer)->transformLocations($locations, $total);
@@ -199,12 +211,19 @@ class LocationsController extends Controller
$location->fill($request->all());
$location = $request->handleImages($location);
// Only scope location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// check if parent is set and has a different company
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
response()->json(Helper::formatStandardApiResponse('error', null, 'different company than parent'));
}
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
if (Setting::getSettings()->full_multiple_companies_support) {
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
if ($parent && $parent->company_id != $location->company_id) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_parent_company', [
'parent' => $parent->name,
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
'location_company' => $location->company?->name ?? trans('general.unassigned'),
])));
}
}
@@ -227,7 +246,19 @@ class LocationsController extends Controller
public function show($id): JsonResponse|array
{
$this->authorize('view', Location::class);
$location = Location::with('parent', 'manager', 'children', 'company')
$location = Location::with([
'parent',
'children',
'company',
'manager' => fn ($q) => $q->withCount([
'assets as assets_count',
'accessories as accessories_count',
'licenses as licenses_count',
'consumables as consumables_count',
'managesUsers as manages_users_count',
'managedLocations as manages_locations_count',
]),
])
->select([
'locations.id',
'locations.name',
@@ -279,18 +310,36 @@ class LocationsController extends Controller
$location = $request->handleImages($location);
if ($request->filled('company_id')) {
// Only scope location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// check if there are related objects with different company
if (Helper::test_locations_fmcs(false, $id, $location->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'error scoped locations'));
if ($mismatched = Helper::test_locations_fmcs(false, $id, $location->company_id)) {
$first = $mismatched[0];
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_scoped_items', [
'item_type' => trans('general.'.strtolower($first[0])),
'item_name' => $first[2],
'item_company' => $first[5] ?? trans('general.unassigned'),
])));
}
} else {
$location->company_id = $request->input('company_id');
}
}
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
// Runs outside the company_id gate so a parent_id-only update is also validated.
if (Setting::getSettings()->full_multiple_companies_support) {
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
if ($parent && $parent->company_id != $location->company_id) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_parent_company', [
'parent' => $parent->name,
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
'location_company' => $location->company?->name ?? trans('general.unassigned'),
])));
}
}
if ($location->isValid()) {
$location->save();
@@ -422,11 +471,6 @@ class LocationsController extends Controller
'locations.tag_color',
]);
// Only scope locations if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$locations = Company::scopeCompanyables($locations);
}
$page = 1;
if ($request->filled('page')) {
$page = $request->input('page');
@@ -436,6 +480,10 @@ class LocationsController extends Controller
$locations = $locations->where('locations.name', 'LIKE', '%'.$request->input('search').'%');
}
if ($request->filled('excludeId')) {
$locations->where('locations.id', '!=', (int) $request->input('excludeId'));
}
$locations = $locations->orderBy('name', 'ASC')->get();
$locations_with_children = [];
@@ -30,7 +30,7 @@ class MaintenanceTypesController extends Controller
$types->where('name', '=', $request->input('name'));
}
$offset = ($request->input('offset') > $types->count()) ? $types->count() : abs($request->input('offset'));
$offset = ($request->input('offset') > $types->count()) ? $types->count() : app('api_offset_value');
$limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort = in_array($request->input('sort'), ['id', 'name', 'created_at', 'updated_at']) ? $request->input('sort') : 'name';
@@ -102,7 +102,7 @@ class MaintenancesController extends Controller
}
// Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $maintenances->count()) ? $maintenances->count() : abs($request->input('offset'));
$offset = ($request->input('offset') > $maintenances->count()) ? $maintenances->count() : app('api_offset_value');
$limit = app('api_limit_value');
$allowed_columns = [
@@ -269,17 +269,33 @@ class MaintenancesController extends Controller
if ($maintenance = Maintenance::with('asset')->find($id)) {
// Can this user manage this asset?
if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $id, 'action' => trans('general.edit')])));
}
// The asset this miantenance is attached to is not valid or has been deleted
// The asset this maintenance is attached to is not valid or has been deleted
if (! $maintenance->asset) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $id])));
}
$maintenance->fill($request->all());
// Can this user manage the existing asset?
if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $id, 'action' => trans('general.edit')])));
}
// If the request changes asset_id, verify the new asset is accessible
if ($request->filled('asset_id') && (int) $request->input('asset_id') !== $maintenance->asset_id) {
$newAsset = Asset::find($request->input('asset_id'));
if (! $newAsset) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $request->input('asset_id')])));
}
if (! Company::isCurrentUserHasAccess($newAsset)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('general.asset'), 'id' => $request->input('asset_id'), 'action' => trans('general.edit')])), 403);
}
$maintenance->fill($request->except('asset_id'));
$maintenance->asset_id = $newAsset->id;
} else {
$maintenance->fill($request->except('asset_id'));
}
if ($maintenance->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.edit.success')));
@@ -6,6 +6,9 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Transformers\PredefinedKitsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory;
use App\Models\Consumable;
use App\Models\License;
use App\Models\PredefinedKit;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -183,6 +186,9 @@ class PredefinedKitsController extends Controller
}
$license_id = $request->input('license');
$license = License::findOrFail($license_id);
$this->authorize('view', $license);
$relation = $kit->licenses();
if ($relation->find($license_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, ['license' => trans('admin/kits/general.license_error')]));
@@ -329,6 +335,9 @@ class PredefinedKitsController extends Controller
}
$consumable_id = $request->input('consumable');
$consumable = Consumable::findOrFail($consumable_id);
$this->authorize('view', $consumable);
$relation = $kit->consumables();
if ($relation->find($consumable_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, ['consumable' => trans('admin/kits/general.consumable_error')]));
@@ -402,6 +411,9 @@ class PredefinedKitsController extends Controller
}
$accessory_id = $request->input('accessory');
$accessory = Accessory::findOrFail($accessory_id);
$this->authorize('view', $accessory);
$relation = $kit->accessories();
if ($relation->find($accessory_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, ['accessory' => trans('admin/kits/general.accessory_error')]));
@@ -52,7 +52,7 @@ class UploadedFilesController extends Controller
$uploads = self::$map_object_type[$object_type]::withTrashed()->find($id)->uploads()
->with('adminuser');
$offset = ($request->input('offset') > $uploads->count()) ? $uploads->count() : abs($request->input('offset'));
$offset = ($request->input('offset') > $uploads->count()) ? $uploads->count() : app('api_offset_value');
$limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
+37 -8
View File
@@ -22,6 +22,7 @@ use App\Models\Asset;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\License;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\CurrentInventory;
use App\Notifications\WelcomeNotification;
@@ -51,7 +52,6 @@ class UsersController extends Controller
'users.address',
'users.avatar',
'users.city',
'users.company_id',
'users.country',
'users.created_by',
'users.created_at',
@@ -89,7 +89,7 @@ class UsersController extends Controller
])->with('manager')
->with('groups')
->with('userloc')
->with('company')
->with('companies')
->with('department')
->with('createdBy')
->withCount([
@@ -191,7 +191,7 @@ class UsersController extends Controller
}
if ($request->filled('company_id')) {
$users = $users->where('users.company_id', '=', $request->input('company_id'));
$users = $users->whereHas('companies', fn ($q) => $q->where('companies.id', $request->input('company_id')));
}
if ($request->filled('phone')) {
@@ -396,6 +396,22 @@ class UsersController extends Controller
]
)->where('show_in_list', '=', '1');
// When FMCS is enabled, automatically scope to companies the acting user belongs to.
// scopeCompanyables is a no-op for superusers and when FMCS is disabled.
$users = Company::scopeCompanyables($users, 'company_id', 'users');
// Allow further narrowing to a specific company passed via data-company-ids on the select.
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
if (! empty($companyIds)) {
$users = Company::scopeUsersByCompanyIds($users, $companyIds);
}
}
if ($request->filled('excludeId')) {
$users->where('users.id', '!=', (int) $request->input('excludeId'));
}
if ($request->filled('search')) {
$users = $users->where(function ($query) use ($request) {
$query->SimpleNameSearch($request->input('search'))
@@ -443,7 +459,6 @@ class UsersController extends Controller
$authenticatedUser = auth()->user();
$user = new User;
$user->fill($request->all());
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$user->created_by = auth()->id();
if ($request->has('permissions')) {
@@ -488,6 +503,12 @@ class UsersController extends Controller
$user->groups()->sync($request->input('groups'));
}
// Sync company memberships from company_ids[] or fall back to scalar company_id
$companyIds = array_filter(
(array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))
);
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser(array_map('intval', $companyIds)));
return response()->json(Helper::formatStandardApiResponse('success', (new UsersTransformer)->transformUser($user), trans('admin/users/message.success.create')));
}
@@ -577,10 +598,6 @@ class UsersController extends Controller
}
if ($request->filled('company_id')) {
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
}
if ($user->id == $request->input('manager_id')) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'You cannot be your own manager'));
}
@@ -609,6 +626,18 @@ class UsersController extends Controller
$user->groups()->sync($request->input('groups'));
}
// company_ids (new format) = full replacement sync.
// Legacy company_id = add without removing other associations.
if ($request->has('company_ids')) {
$companyIds = array_filter(array_map('intval', (array) $request->input('company_ids')));
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
} elseif ($request->filled('company_id')) {
$filtered = Company::getIdsForCurrentUser([(int) $request->input('company_id')]);
if (! empty($filtered)) {
$user->companies()->syncWithoutDetaching($filtered);
}
}
return response()->json(Helper::formatStandardApiResponse('success', (new UsersTransformer)->transformUser($user), trans('admin/users/message.success.update')));
}
@@ -84,7 +84,7 @@ class AssetCheckinController extends Controller
public function store(AssetCheckinRequest $request, $assetId = null, $backto = null): RedirectResponse
{
// Check if the asset exists
if (is_null($asset = Asset::find($assetId))) {
if (is_null($asset = Asset::withTrashed()->find($assetId))) {
// Redirect to the asset management page with error
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
}
@@ -9,7 +9,6 @@ use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
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;
@@ -119,13 +118,18 @@ class AssetCheckoutController extends Controller
// Add any custom fields that should be included in the checkout
$asset->customFieldsForCheckinCheckout('display_checkout');
$settings = Setting::getSettings();
if (! $asset->canCheckoutTo($target)) {
$targetType = match (class_basename($target)) {
'User' => trans('general.user'),
'Location' => trans('general.location'),
default => trans('general.asset'),
};
// We have to check whether $target->company_id is null here since locations don't have a company yet
if (($settings->full_multiple_companies_support) && ((! is_null($target->company_id)) && (! is_null($asset->company_id)))) {
if ($target->company_id != $asset->company_id) {
return redirect()->route('hardware.checkout.create', $asset)->with('error', trans('general.error_user_company'));
}
return redirect()->route('hardware.checkout.create', $asset)->with('error', trans('general.error_checkout_company_mismatch', [
'item' => trans('general.asset').' "'.$asset->display_name.'"',
'item_company' => $asset->company?->name ?? trans('general.unassigned'),
'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
]));
}
session()->put([
@@ -358,7 +358,7 @@ class AssetsController extends Controller
$qr_code = (object) [
'display' => $settings->qr_code == '1',
'url' => route('qr_code/hardware', $asset),
'url' => route('qr_code/common', ['object_type' => 'hardware', 'id' => $asset->id]),
];
$total_maintenance_cost = $asset->maintenances?->sum('cost');
@@ -443,7 +443,7 @@ class AssetsController extends Controller
if ($request->filled('image_delete')) {
try {
unlink(public_path().'/uploads/assets/'.$asset->image);
unlink(public_path().'/uploads/assets/'.basename($asset->image));
$asset->image = '';
} catch (\Exception $e) {
Log::info($e);
@@ -549,7 +549,7 @@ class AssetsController extends Controller
if ($asset->image) {
try {
Storage::disk('public')->delete('assets'.'/'.$asset->image);
Storage::disk('public')->delete('assets/'.basename($asset->image));
} catch (\Exception $e) {
Log::debug($e);
}
@@ -567,11 +567,12 @@ class AssetsController extends Controller
*
* @since [v3.0]
*/
public function getAssetBySerial(Request $request): RedirectResponse
public function getAssetBySerial(Request $request, $serial = null): RedirectResponse
{
$serial = $serial ?: $request->input('serial');
$topsearch = ($request->input('topsearch') == 'true');
if (! $asset = Asset::where('serial', '=', $request->input('serial'))->first()) {
if (! $asset = Asset::where('serial', '=', $serial)->first()) {
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
}
$this->authorize('view', $asset);
@@ -16,6 +16,7 @@ use App\Models\CustomField;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\Statuslabel;
use App\Models\User;
use App\View\Label;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
@@ -687,18 +688,25 @@ class BulkAssetsController extends Controller
->with('error', trans('general.error_assets_already_checked_out'));
}
// Prevent checking out assets across companies if FMCS enabled
if (Setting::getSettings()->full_multiple_companies_support && $target->company_id) {
$company_ids = $assets->pluck('company_id')->unique();
// Prevent checking out assets across companies if FMCS enabled.
if (Setting::getSettings()->full_multiple_companies_support) {
$company_ids = $assets->pluck('company_id')->filter()->unique();
// if there is more than one unique company id or the singular company id does not match
// then the checkout is invalid
if ($company_ids->count() > 1 || $company_ids->first() != $target->company_id) {
// re-add the asset ids so the assets select is re-populated
$request->session()->flashInput(['selected_assets' => $asset_ids]);
if ($company_ids->isNotEmpty()) {
if ($company_ids->count() > 1) {
// Selected assets span multiple companies; bulk checkout can't satisfy all of them.
$mismatch = true;
} else {
// All assets share the same company; let the model enforce the checkout rules.
$mismatch = ! $assets->first()->canCheckoutTo($target);
}
return redirect(route('hardware.bulkcheckout.show'))
->with('error', trans('general.error_user_company_multiple'));
if ($mismatch) {
$request->session()->flashInput(['selected_assets' => $asset_ids]);
return redirect(route('hardware.bulkcheckout.show'))
->with('error', trans('general.error_user_company_multiple'));
}
}
}
@@ -783,7 +791,7 @@ class BulkAssetsController extends Controller
$notAssigned = collect();
if (old('selected_assets') && is_array(old('selected_assets'))) {
$assets = Asset::findMany(old('selected_assets'));
$assets = Asset::withTrashed()->findMany(old('selected_assets'));
[$assigned, $notAssigned] = $assets->partition(function (Asset $asset) {
return $asset->assigned_to;
@@ -814,7 +822,7 @@ class BulkAssetsController extends Controller
$asset_ids = array_filter($request->input('selected_assets'));
$assets = Asset::findOrFail($asset_ids);
$assets = Asset::withTrashed()->findOrFail($asset_ids);
$checkin_at = date('Y-m-d H:i:s');
if ($request->filled('checkin_at') && $request->input('checkin_at') != date('Y-m-d')) {
+2 -1
View File
@@ -75,6 +75,7 @@ class SamlController extends Controller
{
$auth = $this->saml->getAuth();
$ssoUrl = $auth->login(session()->get('url.intended'), [], false, false, false, false);
return redirect()->away($ssoUrl);
}
@@ -95,7 +96,7 @@ class SamlController extends Controller
$saml = $this->saml;
$auth = $saml->getAuth();
$saml_exception = false;
session()->put('url.intended', $request->post('RelayState'));
session()->put('url.intended', str_replace(["\r", "\n"], '', $request->post('RelayState')));
try {
$auth->processResponse();
} catch (\Exception $e) {
@@ -43,7 +43,8 @@ class ComponentCheckinController extends Controller
}
$this->authorize('checkin', $component);
return view('components/checkin', compact('component_assets', 'component', 'asset'));
return view('components/checkin', compact('component_assets', 'component', 'asset'))
->with('snipe_component', $component);
}
return redirect()->route('components.index')->with('error', trans('admin/components/messages.not_found'));
@@ -7,7 +7,6 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\Asset;
use App\Models\Component;
use App\Models\Setting;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@@ -46,7 +45,8 @@ class ComponentCheckoutController extends Controller
}
// Return the checkout view
return view('components/checkout', compact('component'));
return view('components/checkout', compact('component'))
->with('snipe_component', $component);
}
// Invalid category
@@ -103,8 +103,12 @@ class ComponentCheckoutController extends Controller
// Check if the asset exists
$asset = Asset::find($request->input('asset_id'));
if ((Setting::getSettings()->full_multiple_companies_support) && $component->company_id !== $asset->company_id) {
return redirect()->route('components.checkout.show', $componentId)->with('error', trans('general.error_user_company'));
if (! $component->canCheckoutTo($asset)) {
return redirect()->route('components.checkout.show', $componentId)->with('error', trans('general.error_checkout_company_mismatch', [
'item' => trans('general.component').' "'.$component->name.'"',
'item_company' => $component->company?->name ?? trans('general.unassigned'),
'target' => trans('general.asset').' "'.$asset->display_name.'"',
]));
}
$component->checkout_qty = $request->input('assigned_qty');
@@ -96,6 +96,14 @@ class ConsumableCheckoutController extends Controller
return redirect()->route('consumables.checkout.show', $consumable)->with('error', trans('admin/consumables/message.checkout.user_does_not_exist'))->withInput();
}
if (! $consumable->canCheckoutTo($user)) {
return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
'item' => trans('general.consumable').' "'.$consumable->name.'"',
'item_company' => $consumable->company?->name ?? trans('general.unassigned'),
'target' => trans('general.user').' "'.$user->username.'"',
]));
}
// Update the consumable data
$consumable->assigned_to = e($request->input('assigned_to'));
@@ -53,6 +53,8 @@ class CheckoutKitController extends Controller
*/
public function store(Request $request, $kit_id)
{
$this->authorize('checkout', Asset::class);
$user_id = e($request->input('user_id'));
if (is_null($user = User::find($user_id))) {
return redirect()->back()->with('error', trans('admin/users/message.user_not_found'));
@@ -36,7 +36,7 @@ class LicenseCheckinController extends Controller
{
// Check if the asset exists
$license = License::find($licenseSeat->license_id);
$this->authorize('checkout', $license);
$this->authorize('checkin', $license);
return view('licenses/checkin', compact('licenseSeat'))->with('backto', $backTo);
}
@@ -70,7 +70,7 @@ class LicenseCheckinController extends Controller
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkin.error'));
}
$this->authorize('checkout', $license);
$this->authorize('checkin', $license);
// Declare the rules for the form validation
$rules = [
@@ -10,11 +10,13 @@ use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class LicenseCheckoutController extends Controller
@@ -94,23 +96,53 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
}
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId);
$licenseSeat->created_by = auth()->id();
$licenseSeat->notes = $request->input('notes');
if (Setting::getSettings()->full_multiple_companies_support == '1') {
if ($request->filled('asset_id')) {
$fmcsTarget = Asset::find($request->input('asset_id'));
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
'item' => trans('general.license').' "'.$license->name.'"',
'item_company' => $license->company?->name ?? trans('general.unassigned'),
'target' => trans('general.asset').' "'.$fmcsTarget->display_name.'"',
]));
}
} elseif ($request->filled('assigned_to')) {
$fmcsTarget = User::find($request->input('assigned_to'));
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
'item' => trans('general.license').' "'.$license->name.'"',
'item_company' => $license->company?->name ?? trans('general.unassigned'),
'target' => trans('general.user').' "'.$fmcsTarget->username.'"',
]));
}
}
}
$licenseSeat = null;
$checkoutTarget = null;
DB::transaction(function () use ($request, $license, $seatId, &$licenseSeat, &$checkoutTarget): void {
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId, lock: true);
$licenseSeat->created_by = auth()->id();
$licenseSeat->notes = $request->input('notes');
if ($request->filled('asset_id')) {
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
} elseif ($request->filled('assigned_to')) {
$checkoutTarget = $this->checkoutToUser($licenseSeat);
}
});
if ($request->filled('asset_id')) {
session()->put(['checkout_to_type' => 'asset']);
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
$request->request->add(['assigned_asset' => $checkoutTarget->id]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'asset',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
} elseif ($request->filled('assigned_to')) {
session()->put(['checkout_to_type' => 'user']);
$checkoutTarget = $this->checkoutToUser($licenseSeat);
$request->request->add(['assigned_user' => $checkoutTarget->id]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
@@ -156,9 +188,11 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('Something went wrong handling this checkout.'));
}
protected function findLicenseSeatToCheckout($license, $seatId)
protected function findLicenseSeatToCheckout($license, $seatId, bool $lock = false)
{
$licenseSeat = LicenseSeat::find($seatId) ?? $license->freeSeat();
$licenseSeat = $seatId
? LicenseSeat::where('id', $seatId)->when($lock, fn ($q) => $q->lockForUpdate())->first()
: $license->freeSeat(lock: $lock);
if (! $licenseSeat) {
if ($seatId) {
@@ -229,14 +263,10 @@ class LicenseCheckoutController extends Controller
Log::debug('Checking out '.$licenseId.' via bulk');
$license = License::findOrFail($licenseId);
$this->authorize('checkin', $license);
$avail_count = $license->getAvailSeatsCountAttribute();
$this->authorize('checkout', $license);
$users = User::whereNull('deleted_at')->where('autoassign_licenses', '=', 1)->with('licenses')->get();
Log::debug($avail_count.' will be assigned');
if ($users->count() > $avail_count) {
Log::debug('You do not have enough free seats to complete this task, so we will check out as many as we can. ');
if ($license->isInactive()) {
return redirect()->back()->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
}
// If the license is valid, check that there is an available seat
@@ -244,6 +274,19 @@ class LicenseCheckoutController extends Controller
return redirect()->back()->with('error', trans('admin/licenses/general.bulk.checkout_all.error_no_seats'));
}
$avail_count = $license->getAvailSeatsCountAttribute();
$usersQuery = User::whereNull('deleted_at')->where('autoassign_licenses', '=', 1)->with('licenses');
if (Setting::getSettings()->full_multiple_companies_support && $license->company_id) {
$usersQuery->where('company_id', '=', $license->company_id);
}
$users = $usersQuery->get();
Log::debug($avail_count.' will be assigned');
if ($users->count() > $avail_count) {
Log::debug('You do not have enough free seats to complete this task, so we will check out as many as we can. ');
}
$assigned_count = 0;
foreach ($users as $user) {
+34 -12
View File
@@ -89,19 +89,24 @@ class LocationsController extends Controller
$location->fax = request('fax');
$location->tag_color = $request->input('tag_color');
$location->notes = $request->input('notes');
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// Only scope the location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// check if parent is set and has a different company
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
return redirect()->back()->withInput()->withInput()->with('error', 'different company than parent');
}
} else {
$location->company_id = $request->input('company_id');
}
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
if (Setting::getSettings()->full_multiple_companies_support) {
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
if ($parent && $parent->company_id != $location->company_id) {
return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
'parent' => $parent->name,
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
'location_company' => $location->company?->name ?? trans('general.unassigned'),
]));
}
}
if ($request->has('use_cloned_image')) {
$cloned_model_img = Location::select('image')->find($request->input('clone_image_from_id'));
if ($cloned_model_img) {
@@ -171,17 +176,34 @@ class LocationsController extends Controller
$location->tag_color = $request->input('tag_color');
$location->notes = $request->input('notes');
// Only scope the location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// check if there are related objects with different company
if (Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
return redirect()->back()->withInput()->withInput()->with('error', 'error scoped locations');
if ($mismatched = Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
$first = $mismatched[0];
return redirect()->back()->withInput()->with('error', trans('general.error_location_scoped_items', [
'item_type' => trans('general.'.strtolower($first[0])),
'item_name' => $first[2],
'item_company' => $first[5] ?? trans('general.unassigned'),
]));
}
} else {
$location->company_id = $request->input('company_id');
}
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
if (Setting::getSettings()->full_multiple_companies_support) {
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
if ($parent && $parent->company_id != $location->company_id) {
return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
'parent' => $parent->name,
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
'location_company' => $location->company?->name ?? trans('general.unassigned'),
]));
}
}
$location = $request->handleImages($location);
if ($location->save()) {
@@ -277,7 +299,7 @@ class LocationsController extends Controller
->with('assignedAssets', $location->assignedAssets)
->with('accessories', $location->accessories)
->with('assignedAccessories', $location->assignedAccessories)
->with('users', $location->users)
->with('users', $location->users()->with('companies')->get())
->with('location', $location)
->with('consumables', $location->consumables)
->with('components', $location->components)
@@ -297,7 +319,7 @@ class LocationsController extends Controller
->with('assignedAssets', $location->assignedAssets)
->with('accessories', $location->accessories)
->with('assignedAccessories', $location->assignedAccessories)
->with('users', $location->users)
->with('users', $location->users()->with('companies')->get())
->with('location', $location)
->with('consumables', $location->consumables)
->with('components', $location->components)
@@ -8,6 +8,7 @@ use App\Models\Asset;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\CurrentInventory;
use App\Rules\CssColor;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -63,6 +64,12 @@ class ProfileController extends Controller
$user->enable_sounds = $request->input('enable_sounds', false);
$user->enable_confetti = $request->input('enable_confetti', false);
$request->validate([
'link_light_color' => ['nullable', new CssColor],
'link_dark_color' => ['nullable', new CssColor],
'nav_link_color' => ['nullable', new CssColor],
]);
$user->link_light_color = $request->input('link_light_color', '#296282');
$user->link_dark_color = $request->input('link_dark_color', '#296282');
$user->nav_link_color = $request->input('nav_link_color', '#FFFFFF');
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\Helper;
use App\Models\Setting;
use Com\Tecnick\Barcode\Barcode;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class QrCodeController extends Controller
{
public static $map_show_route = [
'accessories' => 'accessories.show',
'assets' => 'hardware.show',
'companies' => 'companies.show',
'components' => 'components.show',
'consumables' => 'consumables.show',
'hardware' => 'hardware.show',
'licenses' => 'licenses.show',
'locations' => 'locations.show',
'models' => 'models.show',
'users' => 'users.show',
];
public function show($object_type, $id): Response|BinaryFileResponse|string|bool
{
$settings = Setting::getSettings();
if ($settings->label2_2d_type === 'none') {
return false;
}
if (! array_key_exists($object_type, self::$map_show_route)) {
return $object_type.' is not a valid type.';
}
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
if (! $object) {
return 'That item is invalid';
}
$this->authorize('view', $object);
$size = Helper::barcodeDimensions($settings->label2_2d_type);
$qr_file = public_path().'/uploads/barcodes/qr-'.str_slug($object_type).'-'.str_slug($id).'.png';
if (file_exists($qr_file)) {
return response()->file($qr_file, ['Content-type' => 'image/png']);
}
$barcode = new Barcode;
$barcode_obj = $barcode->getBarcodeObj(
$settings->label2_2d_type,
route(self::$map_show_route[$object_type], $id),
$size['height'],
$size['width'],
'black',
[-2, -2, -2, -2]
);
file_put_contents($qr_file, $barcode_obj->getPngData());
return response($barcode_obj->getPngData())->header('Content-type', 'image/png');
}
}
+230 -164
View File
@@ -36,8 +36,6 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use League\Csv\EscapeFormula;
use League\Csv\Reader;
use League\Csv\Writer;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -105,36 +103,46 @@ class ReportsController extends Controller
* @see ManufacturersController::getDatatable() method that generates the JSON response
* @since [v1.0]
*/
public function exportAccessoryReport(): Response
public function exportAccessoryReport(): StreamedResponse
{
$this->authorize('reports.view');
$accessories = Accessory::orderBy('created_at', 'DESC')->get();
$rows = [];
$header = [
trans('admin/accessories/table.title'),
trans('admin/accessories/general.accessory_category'),
trans('admin/accessories/general.total'),
trans('admin/accessories/general.remaining'),
];
$header = array_map('trim', $header);
$rows[] = implode(', ', $header);
$response = new StreamedResponse(function () {
$handle = fopen('php://output', 'w');
// Row per accessory
foreach ($accessories as $accessory) {
$row = [];
$row[] = e($accessory->accessory_name);
$row[] = e($accessory->accessory_category);
$row[] = e($accessory->total);
$row[] = e($accessory->remaining);
$header = [
trans('admin/accessories/table.title'),
trans('admin/accessories/general.accessory_category'),
trans('admin/accessories/general.total'),
trans('admin/accessories/general.remaining'),
];
fputcsv($handle, $header);
$rows[] = implode(',', $row);
}
$formatter = new EscapeFormula('`');
$csv = implode("\n", $rows);
$response = response()->make($csv, 200);
$response->header('Content-Type', 'text/csv');
$response->header('Content-disposition', 'attachment;filename=report.csv');
Accessory::with('category')->orderBy('created_at', 'DESC')
->chunk(500, function ($accessories) use ($handle, $formatter) {
foreach ($accessories as $accessory) {
$row = [
$accessory->name,
$accessory->category?->name,
$accessory->qty,
$accessory->numRemaining(),
];
if (config('app.escape_formulas') === false) {
fputcsv($handle, $row);
} else {
fputcsv($handle, $formatter->escapeRecord($row));
}
}
});
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="accessories-report-'.date('Y-m-d-his').'.csv"',
]);
return $response;
}
@@ -163,74 +171,80 @@ class ReportsController extends Controller
*
* @since [v1.0]
*/
public function exportDeprecationReport(): Response
public function exportDeprecationReport(): StreamedResponse
{
$this->authorize('reports.view');
// Grab all the assets
$assets = Asset::with('model', 'assignedTo', 'status', 'defaultLoc', 'assetlog')
->orderBy('created_at', 'DESC')->get();
$csv = Writer::createFromFileObject(new \SplTempFileObject);
$csv->setOutputBOM(Reader::BOM_UTF16_BE);
$response = new StreamedResponse(function () {
$handle = fopen('php://output', 'w');
$formatter = new EscapeFormula('`');
$rows = [];
$header = [
trans('admin/hardware/table.asset_tag'),
trans('admin/hardware/table.title'),
trans('admin/hardware/table.serial'),
trans('admin/hardware/table.checkoutto'),
trans('admin/hardware/table.location'),
trans('admin/hardware/table.purchase_date'),
trans('admin/hardware/table.purchase_cost'),
trans('admin/hardware/table.book_value'),
trans('admin/hardware/table.diff'),
];
fputcsv($handle, $header);
// Create the header row
$header = [
trans('admin/hardware/table.asset_tag'),
trans('admin/hardware/table.title'),
trans('admin/hardware/table.serial'),
trans('admin/hardware/table.checkoutto'),
trans('admin/hardware/table.location'),
trans('admin/hardware/table.purchase_date'),
trans('admin/hardware/table.purchase_cost'),
trans('admin/hardware/table.book_value'),
trans('admin/hardware/table.diff'),
];
Asset::with('model', 'assignedTo', 'status', 'defaultLoc', 'assetlog')
->orderBy('created_at', 'DESC')
->chunk(500, function ($assets) use ($handle, $formatter) {
foreach ($assets as $asset) {
$currency = $asset->location
? $asset->location->currency
: Setting::getSettings()->default_currency;
// we insert the CSV header
$csv->insertOne($header);
if ($target = $asset->assignedTo) {
$assignedTo = $target->display_name;
} else {
$assignedTo = '';
}
// Create a row per asset
foreach ($assets as $asset) {
$row = [];
$row[] = e($asset->asset_tag);
$row[] = e($asset->name);
$row[] = e($asset->serial);
if (($asset->assigned_to > 0) && ($location = $asset->location)) {
if ($location->city) {
$locationStr = $location->city.', '.$location->state;
} elseif ($location->name) {
$locationStr = $location->name;
} else {
$locationStr = '';
}
} else {
$locationStr = '';
}
if ($target = $asset->assignedTo) {
$row[] = e($target->display_name);
} else {
$row[] = ''; // Empty string if unassigned
}
$row = [
$asset->asset_tag,
$asset->name,
$asset->serial,
$assignedTo,
$locationStr,
Helper::getFormattedDateObject($asset->purchase_date, 'date', false),
$currency.Helper::formatCurrencyOutput($asset->purchase_cost),
$currency.Helper::formatCurrencyOutput($asset->getDepreciatedValue()),
$currency.Helper::formatCurrencyOutput($asset->purchase_cost - $asset->getDepreciatedValue()),
];
if (($asset->assigned_to > 0) && ($location = $asset->location)) {
if ($location->city) {
$row[] = e($location->city).', '.e($location->state);
} elseif ($location->name) {
$row[] = e($location->name);
} else {
$row[] = '';
}
} else {
$row[] = ''; // Empty string if location is not set
}
if (config('app.escape_formulas') === false) {
fputcsv($handle, $row);
} else {
fputcsv($handle, $formatter->escapeRecord($row));
}
}
});
if ($asset->location) {
$currency = e($asset->location->currency);
} else {
$currency = e(Setting::getSettings()->default_currency);
}
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="depreciation-report-'.date('Y-m-d-his').'.csv"',
]);
$row[] = Helper::getFormattedDateObject($asset->purchase_date, 'date', false);
$row[] = $currency.Helper::formatCurrencyOutput($asset->purchase_cost);
$row[] = $currency.Helper::formatCurrencyOutput($asset->getDepreciatedValue());
$row[] = $currency.Helper::formatCurrencyOutput(($asset->purchase_cost - $asset->getDepreciatedValue()));
$csv->insertOne($row);
}
$csv->output('depreciation-report-'.date('Y-m-d').'.csv');
exit;
return $response;
}
/**
@@ -395,45 +409,52 @@ class ReportsController extends Controller
*
* @since [v1.0]
*/
public function exportLicenseReport(): Response
public function exportLicenseReport(): StreamedResponse
{
$this->authorize('reports.view');
$licenses = License::orderBy('created_at', 'DESC')->get();
$rows = [];
$header = [
trans('admin/licenses/table.title'),
trans('admin/licenses/table.serial'),
trans('admin/licenses/form.seats'),
trans('admin/licenses/form.remaining_seats'),
trans('admin/licenses/form.expiration'),
trans('general.purchase_date'),
trans('general.depreciation'),
trans('general.purchase_cost'),
];
$response = new StreamedResponse(function () {
$handle = fopen('php://output', 'w');
$formatter = new EscapeFormula('`');
$header = array_map('trim', $header);
$rows[] = implode(', ', $header);
$header = [
trans('admin/licenses/table.title'),
trans('admin/licenses/table.serial'),
trans('admin/licenses/form.seats'),
trans('admin/licenses/form.remaining_seats'),
trans('admin/licenses/form.expiration'),
trans('general.purchase_date'),
trans('general.depreciation'),
trans('general.purchase_cost'),
];
fputcsv($handle, $header);
// Row per license
foreach ($licenses as $license) {
$row = [];
$row[] = e($license->name);
$row[] = e($license->serial);
$row[] = e($license->seats);
$row[] = $license->remaincount();
$row[] = $license->expiration_date;
$row[] = $license->purchase_date;
$row[] = ($license->depreciation != '') ? e($license->depreciation->name) : '';
$row[] = '"'.Helper::formatCurrencyOutput($license->purchase_cost).'"';
License::orderBy('created_at', 'DESC')->chunk(500, function ($licenses) use ($handle, $formatter) {
foreach ($licenses as $license) {
$row = [
$license->name,
$license->serial,
$license->seats,
$license->remaincount(),
$license->expiration_date,
$license->purchase_date,
($license->depreciation != '') ? $license->depreciation->name : '',
Helper::formatCurrencyOutput($license->purchase_cost),
];
$rows[] = implode(',', $row);
}
if (config('app.escape_formulas') === false) {
fputcsv($handle, $row);
} else {
fputcsv($handle, $formatter->escapeRecord($row));
}
}
});
$csv = implode("\n", $rows);
$response = response()->make($csv, 200);
$response->header('Content-Type', 'text/csv');
$response->header('Content-disposition', 'attachment;filename=report.csv');
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="licenses-report-'.date('Y-m-d-his').'.csv"',
]);
return $response;
}
@@ -778,12 +799,11 @@ class ReportsController extends Controller
$checkout_start = Carbon::parse($request->input('checkout_date_start'))->startOfDay();
$checkout_end = Carbon::parse($request->input('checkout_date_end', now()))->endOfDay();
$actionlogassets = Actionlog::where('action_type', '=', 'checkout')
->where('item_type', 'LIKE', '%Asset%')
->whereBetween('action_date', [$checkout_start, $checkout_end])
->pluck('item_id');
$actionlogassets = Actionlog::select('id')->where('action_type', '=', 'checkout')
->where('item_type', '=', Asset::class)
->whereBetween('action_date', [$checkout_start, $checkout_end]); // we are *not* doing ->get()...
$assets->whereIn('assets.id', $actionlogassets);
$assets->whereIn('id', $actionlogassets); // ...because this _should_ act as a 'subquery'
}
if (($request->filled('checkin_date_start'))) {
@@ -1172,56 +1192,60 @@ class ReportsController extends Controller
*
* @version v1.0
*/
public function exportMaintenancesReport(): Response
public function exportMaintenancesReport(): StreamedResponse
{
$this->authorize('reports.view');
// Grab all the improvements
$Maintenances = Maintenance::with('asset', 'supplier')
->orderBy('created_at', 'DESC')
->get();
$rows = [];
$response = new StreamedResponse(function () {
$handle = fopen('php://output', 'w');
$formatter = new EscapeFormula('`');
$header = [
trans('admin/hardware/table.asset_tag'),
trans('admin/maintenances/table.asset_name'),
trans('general.supplier'),
trans('admin/maintenances/form.asset_maintenance_type'),
trans('admin/maintenances/form.title'),
trans('admin/maintenances/form.start_date'),
trans('admin/maintenances/form.completion_date'),
trans('admin/maintenances/form.asset_maintenance_time'),
trans('admin/maintenances/form.cost'),
];
$header = [
trans('admin/hardware/table.asset_tag'),
trans('admin/maintenances/table.asset_name'),
trans('general.supplier'),
trans('admin/maintenances/form.asset_maintenance_type'),
trans('admin/maintenances/form.title'),
trans('admin/maintenances/form.start_date'),
trans('admin/maintenances/form.completion_date'),
trans('admin/maintenances/form.asset_maintenance_time'),
trans('admin/maintenances/form.cost'),
];
fputcsv($handle, $header);
$header = array_map('trim', $header);
$rows[] = implode(',', $header);
Maintenance::with('asset', 'supplier')
->orderBy('created_at', 'DESC')
->chunk(500, function ($maintenances) use ($handle, $formatter) {
foreach ($maintenances as $maintenance) {
$improvementTime = is_null($maintenance->asset_maintenance_time)
? (int) Carbon::now()->diffInDays(Carbon::parse($maintenance->start_date), true)
: (int) $maintenance->asset_maintenance_time;
foreach ($Maintenances as $maintenance) {
$row = [];
$row[] = str_replace(',', '', e($maintenance->asset->asset_tag));
$row[] = str_replace(',', '', e($maintenance->asset->name));
$row[] = str_replace(',', '', e($maintenance->supplier->name));
$row[] = e($maintenance->improvement_type);
$row[] = e($maintenance->name);
$row[] = e($maintenance->start_date);
$row[] = e($maintenance->completion_date);
if (is_null($maintenance->asset_maintenance_time)) {
$improvementTime = (int) Carbon::now()
->diffInDays(Carbon::parse($maintenance->start_date), true);
} else {
$improvementTime = (int) $maintenance->asset_maintenance_time;
}
$row[] = $improvementTime;
$row[] = trans('general.currency').Helper::formatCurrencyOutput($maintenance->cost);
$rows[] = implode(',', $row);
}
$row = [
$maintenance->asset->asset_tag,
$maintenance->asset->name,
$maintenance->supplier->name,
$maintenance->improvement_type,
$maintenance->name,
$maintenance->start_date,
$maintenance->completion_date,
$improvementTime,
trans('general.currency').Helper::formatCurrencyOutput($maintenance->cost),
];
// spit out a csv
$csv = implode("\n", $rows);
$response = response()->make($csv, 200);
$response->header('Content-Type', 'text/csv');
$response->header('Content-disposition', 'attachment;filename=report.csv');
if (config('app.escape_formulas') === false) {
fputcsv($handle, $row);
} else {
fputcsv($handle, $formatter->escapeRecord($row));
}
}
});
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="maintenances-report-'.date('Y-m-d-his').'.csv"',
]);
return $response;
}
@@ -1300,6 +1324,11 @@ class ReportsController extends Controller
// Redirect to the unaccepted items report page with error
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
}
if (! $this->currentUserCanAccessAcceptance($acceptance)) {
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.insufficient_permissions'));
}
$item = $acceptance->checkoutable;
$assignee = $acceptance->assignedTo ?? $item->assignedTo ?? null;
$email = $assignee?->email;
@@ -1334,6 +1363,33 @@ class ReportsController extends Controller
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.reminder_sent'));
}
private function currentUserCanAccessAcceptance(CheckoutAcceptance $acceptance): bool
{
if (Setting::getSettings()->full_multiple_companies_support != '1') {
return true;
}
$user = auth()->user();
if (! $user->company_id || $user->isSuperUser()) {
return true;
}
// Bypass Eloquent global scopes so cross-company items are still found
$checkoutableType = $acceptance->checkoutable_type;
$checkoutable = $checkoutableType::withoutGlobalScopes()->find($acceptance->checkoutable_id);
if ($checkoutable instanceof LicenseSeat) {
$itemCompanyId = License::withoutGlobalScopes()
->where('id', $checkoutable->license_id)
->value('company_id');
} else {
$itemCompanyId = $checkoutable?->company_id;
}
return $itemCompanyId === null || (int) $itemCompanyId === (int) $user->company_id;
}
private function getCheckoutMailType(CheckoutAcceptance $acceptance, $logItem): Mailable
{
$lookup = [
@@ -1366,11 +1422,21 @@ class ReportsController extends Controller
{
$this->authorize('reports.view');
if (! $acceptance = CheckoutAcceptance::pending()->find($acceptanceId)) {
$acceptance = CheckoutAcceptance::pending()
->with(['checkoutable' => function (MorphTo $morphTo) {
$morphTo->morphWith([LicenseSeat::class => ['license']]);
}])
->find($acceptanceId);
if (! $acceptance) {
// Redirect to the unaccepted assets report page with error
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
}
if (! $this->currentUserCanAccessAcceptance($acceptance)) {
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.insufficient_permissions'));
}
if ($acceptance->delete()) {
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.acceptance_deleted'));
} else {
+11 -1
View File
@@ -19,6 +19,7 @@ use App\Models\Group;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\MailTest;
use App\Rules\CssColor;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -92,10 +93,12 @@ class SettingsController extends Controller
$old_locations_fmcs = $setting->scope_locations_fmcs;
$setting->full_multiple_companies_support = $request->input('full_multiple_companies_support', '0');
$setting->scope_locations_fmcs = $request->input('scope_locations_fmcs', '0');
$setting->null_company_is_floater = $request->input('null_company_is_floater', '0');
// Backward compatibility for locations makes no sense without FullMultipleCompanySupport
// These options make no sense without FullMultipleCompanySupport
if (! $setting->full_multiple_companies_support) {
$setting->scope_locations_fmcs = '0';
$setting->null_company_is_floater = '0';
}
// check for inconsistencies when activating scoped locations
@@ -189,6 +192,13 @@ class SettingsController extends Controller
$request->validate(['site_name' => 'required']);
}
$request->validate([
'header_color' => ['nullable', new CssColor],
'link_light_color' => ['nullable', new CssColor],
'link_dark_color' => ['nullable', new CssColor],
'nav_link_color' => ['nullable', new CssColor],
]);
$setting->header_color = $request->input('header_color', '#3c8dbc');
$setting->link_light_color = $request->input('link_light_color', '#296282');
$setting->link_dark_color = $request->input('link_dark_color', '#5fa4cc');
+7
View File
@@ -6,6 +6,7 @@ use App\Http\Requests\SetupUserRequest;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\FirstAdminNotification;
use App\Rules\CssColor;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
@@ -166,6 +167,12 @@ class SetupController extends Controller
$settings->alerts_enabled = 1;
$settings->pwd_secure_min = 10;
$settings->brand = 1;
$request->validate([
'link_light_color' => ['nullable', new CssColor],
'link_dark_color' => ['nullable', new CssColor],
'nav_link_color' => ['nullable', new CssColor],
]);
$settings->link_light_color = $request->input('link_light_color', '#296282');
$settings->link_dark_color = $request->input('link_dark_color', '#296282');
$settings->nav_link_color = $request->input('nav_link_color', '#FFFFFF');
@@ -101,11 +101,13 @@ class UploadedFilesController extends Controller
}
if (request('inline') == 'true') {
$headers = [
'Content-Disposition' => 'inline',
];
$path = self::$map_storage_path[$object_type];
return Storage::download(self::$map_storage_path[$object_type].$log->filename, $log->filename, $headers);
if (! StorageHelper::allowSafeInline($path.$log->filename)) {
return StorageHelper::downloader($path.$log->filename);
}
return Storage::download($path.$log->filename, $log->filename, ['Content-Disposition' => 'inline']);
}
return StorageHelper::downloader(self::$map_storage_path[$object_type].$log->filename);
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\ConsumableAssignment;
use App\Models\Group;
use App\Models\License;
@@ -168,14 +169,21 @@ class BulkUsersController extends Controller
$this->conditionallyAddItem('location_id')
->conditionallyAddItem('department_id')
->conditionallyAddItem('company_id')
->conditionallyAddItem('locale')
->conditionallyAddItem('remote')
->conditionallyAddItem('display_name')
->conditionallyAddItem('start_date')
->conditionallyAddItem('end_date')
->conditionallyAddItem('city')
->conditionallyAddItem('autoassign_licenses');
->conditionallyAddItem('autoassign_licenses')
->conditionallyAddItem('phone')
->conditionallyAddItem('jobtitle')
->conditionallyAddItem('address')
->conditionallyAddItem('state')
->conditionallyAddItem('country')
->conditionallyAddItem('zip')
->conditionallyAddItem('website')
->conditionallyAddItem('notes');
// If the manager_id is one of the users being updated, generate a warning.
if (array_search($request->input('manager_id'), $user_raw_array)) {
@@ -200,7 +208,7 @@ class BulkUsersController extends Controller
$this->update_array['manager_id'] = null;
}
if ($request->input('null_company_id') == '1') {
if ($request->input('null_company_ids') == '1') {
$this->update_array['company_id'] = null;
}
@@ -220,6 +228,46 @@ class BulkUsersController extends Controller
$this->update_array['display_name'] = null;
}
if ($request->input('null_city') == '1') {
$this->update_array['city'] = null;
}
if ($request->input('null_phone') == '1') {
$this->update_array['phone'] = null;
}
if ($request->input('null_jobtitle') == '1') {
$this->update_array['jobtitle'] = null;
}
if ($request->input('null_employee_num') == '1') {
$this->update_array['employee_num'] = null;
}
if ($request->input('null_address') == '1') {
$this->update_array['address'] = null;
}
if ($request->input('null_state') == '1') {
$this->update_array['state'] = null;
}
if ($request->input('null_country') == '1') {
$this->update_array['country'] = null;
}
if ($request->input('null_zip') == '1') {
$this->update_array['zip'] = null;
}
if ($request->input('null_website') == '1') {
$this->update_array['website'] = null;
}
if ($request->input('null_notes') == '1') {
$this->update_array['notes'] = null;
}
if (! $manager_conflict) {
$this->conditionallyAddItem('manager_id');
}
@@ -233,6 +281,30 @@ class BulkUsersController extends Controller
->update(['location_id' => $this->update_array['location_id']]);
}
// Handle company pivot sync separately from the mass update.
// company_ids[] comes from the multi-select; null_company_ids clears all memberships.
$bulkCompanyIds = array_filter(array_map('intval', (array) $request->input('company_ids', [])));
$clearCompanies = $request->input('null_company_ids') == '1';
if ($bulkCompanyIds || $clearCompanies) {
$allowedIds = Company::getIdsForCurrentUser($bulkCompanyIds);
// Also update the scalar company_id column for display/backward compat.
$scalarCompanyId = $allowedIds[0] ?? null;
User::whereIn('id', $user_raw_array)->where('id', '!=', auth()->id())
->update(['company_id' => $scalarCompanyId]);
foreach ($users as $user) {
if ($clearCompanies && ! auth()->user()->isSuperUser() && Company::isFullMultipleCompanySupportEnabled()) {
// Non-superusers can only detach companies they belong to; sync([]) would
// also wipe memberships for companies outside their scope.
$user->companies()->detach(Company::getIdsForCurrentUser(
$user->companies()->pluck('companies.id')->toArray()
));
} else {
$user->companies()->sync($allowedIds);
}
}
}
// Fields that require canEditAuthFields (non-admins cannot touch admins/superusers,
// admins cannot touch superusers) must be applied per-user, not via mass update.
foreach ($users as $user) {
@@ -244,6 +316,11 @@ class BulkUsersController extends Controller
if ($request->filled('ldap_import')) {
$authFieldUpdate['ldap_import'] = $request->input('ldap_import');
}
if ($request->filled('email')) {
$authFieldUpdate['email'] = $request->input('email');
} elseif ($request->input('null_email') == '1') {
$authFieldUpdate['email'] = null;
}
if (! empty($authFieldUpdate)) {
$user->update($authFieldUpdate);
}
@@ -318,6 +395,31 @@ class BulkUsersController extends Controller
return redirect()->route('users.index')->with('error', 'No status selected');
}
// Enforce per-item checkin permissions before touching anything (catches FMCS company scoping).
foreach ($assets as $asset) {
if (auth()->user()->cannot('checkin', $asset)) {
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
}
}
$licenseModels = License::whereIn('id', $licenses->pluck('license_id')->unique())->get();
foreach ($licenseModels as $license) {
if (auth()->user()->cannot('checkin', $license)) {
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
}
}
$accessoryModels = Accessory::whereIn('id', $accessoryUserRows->pluck('accessory_id')->unique())->get();
foreach ($accessoryModels as $accessory) {
if (auth()->user()->cannot('checkin', $accessory)) {
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
}
}
if ($request->input('delete_user') == '1' && $users->isNotEmpty() && auth()->user()->cannot('delete', User::class)) {
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
}
$this->logItemCheckinAndDelete($assets, Asset::class);
$this->logAccessoriesCheckin($accessoryUserRows);
$this->logItemCheckinAndDelete($licenses, License::class);
@@ -424,6 +526,10 @@ class BulkUsersController extends Controller
$users_to_merge = User::whereIn('id', $user_ids_to_merge)->with('assets', 'manager', 'userlog', 'licenses', 'consumables', 'accessories', 'managedLocations', 'uploads', 'acceptances')->get();
$admin = User::find(auth()->id());
if (! auth()->user()->can('canEditAuthFields', $merge_into_user) || ! auth()->user()->can('editableOnDemo')) {
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
}
// Walk users
foreach ($users_to_merge as $user_to_merge) {
@@ -473,6 +579,12 @@ class BulkUsersController extends Controller
$managedLocation->save();
}
// Carry over company pivot memberships from the merged user into the target.
$mergedCompanyIds = $user_to_merge->companies()->pluck('companies.id')->toArray();
if (! empty($mergedCompanyIds)) {
$merge_into_user->companies()->syncWithoutDetaching($mergedCompanyIds);
}
$user_to_merge->delete();
event(new UserMerged($user_to_merge, $merge_into_user, $admin));
+21 -7
View File
@@ -10,11 +10,14 @@ use App\Http\Requests\DeleteUserRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\SaveUserRequest;
use App\Mail\UnacceptedAssetReminderMail;
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\Group;
use App\Models\License;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\CurrentInventory;
@@ -123,7 +126,7 @@ class UsersController extends Controller
$user->mobile = $request->input('mobile');
$user->location_id = $request->input('location_id', null);
$user->department_id = $request->input('department_id', null);
$user->company_id = Company::getIdForUser($request->input('company_id', null));
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
$user->manager_id = $request->input('manager_id', null);
$user->notes = $request->input('notes');
$user->address = $request->input('address', null);
@@ -153,6 +156,7 @@ class UsersController extends Controller
}
if ($user->save()) {
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
if (($user->activated == '1') && ($user->email != '') && ($request->input('send_welcome') == '1')) {
@@ -275,7 +279,7 @@ class UsersController extends Controller
$user->phone = $request->input('phone');
$user->mobile = $request->input('mobile');
$user->location_id = $request->input('location_id', null);
$user->company_id = Company::getIdForUser($request->input('company_id', null));
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
$user->manager_id = $request->input('manager_id', null);
$user->notes = $request->input('notes');
$user->department_id = $request->input('department_id', null);
@@ -336,6 +340,8 @@ class UsersController extends Controller
session()->put(['redirect_option' => $request->input('redirect_option')]);
if ($user->save()) {
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
// Redirect to the user page
return Helper::getRedirectOption($request, $user->id, 'Users')
->with('success', trans('admin/users/message.success.update'));
@@ -480,7 +486,7 @@ class UsersController extends Controller
$permissions = $request->input('permissions', []);
app('request')->request->set('permissions', $permissions);
$user_to_clone = User::with('userloc')->withTrashed()->find($user->id);
$user_to_clone = User::with('userloc', 'companies')->withTrashed()->find($user->id);
// Make sure they can view this particular user
$this->authorize('view', $user_to_clone);
@@ -598,7 +604,7 @@ class UsersController extends Controller
'manager',
'groups',
'userloc',
'company',
'companies',
'createdBy'
)->withCount(['managesUsers as manages_users_count', 'managedLocations as manages_locations_count'])
->orderBy('created_at', 'DESC')
@@ -620,7 +626,7 @@ class UsersController extends Controller
// Add a new row with data
$values = [
$user->id,
($user->company) ? $user->company->name : '',
$user->companies->pluck('name')->implode('|'),
$user->jobtitle,
$user->employee_num,
$user->first_name,
@@ -699,9 +705,17 @@ class UsersController extends Controller
{
$this->authorize('view', User::class);
$user = User::withInventoryRelations($id)->first();
$actor = auth()->user();
$canViewLicenses = $actor->can('view', License::class);
$canViewAccessories = $actor->can('view', Accessory::class);
$canViewConsumables = $actor->can('view', Consumable::class);
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
$user = User::withInventoryRelations($id, $canViewLicenses, $canViewAccessories, $canViewConsumables)->first();
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count()
+ $user?->assets?->flatMap->components->count()
+ ($canViewLicenses ? $user?->assets?->flatMap->licenses->count() : 0)
+ ($canViewAccessories ? $user?->assets?->flatMap->assignedAccessories->count() : 0);
if ($user) {
$this->authorize('view', $user);
@@ -121,6 +121,7 @@ class ViewAssetsController extends Controller
'consumables',
'accessories',
'licenses',
'companies',
])->find($selectedUserId);
// If the user to view couldn't be found (shouldn't happen with proper logic), redirect with error
@@ -221,6 +222,10 @@ class ViewAssetsController extends Controller
return redirect()->back()->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
} else {
if ($fullItemType === Asset::class && is_null(Asset::RequestableAssets()->find($item->id))) {
return redirect()->back()->with('error', trans('admin/hardware/message.requests.error'));
}
$item->request();
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$logaction->logaction('requested');
+2 -2
View File
@@ -2,7 +2,6 @@
namespace App\Http;
use App\Http\Middleware\AssetCountForSidebar;
use App\Http\Middleware\CheckColorSettings;
use App\Http\Middleware\CheckForDebug;
use App\Http\Middleware\CheckForSetup;
@@ -17,6 +16,7 @@ use App\Http\Middleware\PreventBackHistory;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SetAPIResponseHeaders;
use App\Http\Middleware\SetPaginationDefaults;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\VerifyCsrfToken;
@@ -74,7 +74,6 @@ class Kernel extends HttpKernel
CheckUserIsActivated::class,
CheckForTwoFactor::class,
CreateFreshApiToken::class,
AssetCountForSidebar::class,
CheckColorSettings::class,
AuthenticateSession::class,
SubstituteBindings::class,
@@ -84,6 +83,7 @@ class Kernel extends HttpKernel
'auth:api',
CheckLocale::class,
LogAuthedUserHeader::class,
SetPaginationDefaults::class,
SubstituteBindings::class,
],
@@ -1,119 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Models\Asset;
use App\Models\Setting;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class AssetCountForSidebar
{
/**
* Handle an incoming request.
*
* @param Request $request
* @return mixed
*/
public function handle($request, Closure $next)
{
/**
* This needs to be set for the /setup process, since the tables might not exist yet
*/
$total_assets = 0;
$total_due_for_checkin = 0;
$total_overdue_for_checkin = 0;
$total_due_for_audit = 0;
$total_overdue_for_audit = 0;
try {
$settings = Setting::getSettings();
view()->share('settings', $settings);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_assets = Asset::AssetsForShow()->count();
view()->share('total_assets', $total_assets);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_rtd_sidebar = Asset::RTD()->count();
view()->share('total_rtd_sidebar', $total_rtd_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_deployed_sidebar = Asset::Deployed()->count();
view()->share('total_deployed_sidebar', $total_deployed_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_archived_sidebar = Asset::Archived()->count();
view()->share('total_archived_sidebar', $total_archived_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_pending_sidebar = Asset::Pending()->count();
view()->share('total_pending_sidebar', $total_pending_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_undeployable_sidebar = Asset::Undeployable()->count();
view()->share('total_undeployable_sidebar', $total_undeployable_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_byod_sidebar = Asset::where('byod', '=', '1')->count();
view()->share('total_byod_sidebar', $total_byod_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_due_for_audit = Asset::DueForAudit($settings)->count();
view()->share('total_due_for_audit', $total_due_for_audit);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_overdue_for_audit = Asset::OverdueForAudit()->count();
view()->share('total_overdue_for_audit', $total_overdue_for_audit);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_due_for_checkin = Asset::DueForCheckin($settings)->count();
view()->share('total_due_for_checkin', $total_due_for_checkin);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_overdue_for_checkin = Asset::OverdueForCheckin()->count();
view()->share('total_overdue_for_checkin', $total_overdue_for_checkin);
} catch (\Exception $e) {
Log::debug($e);
}
view()->share('total_due_and_overdue_for_checkin', ($total_due_for_checkin + $total_overdue_for_checkin));
view()->share('total_due_and_overdue_for_audit', ($total_due_for_audit + $total_overdue_for_audit));
return $next($request);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SetPaginationDefaults
{
public function handle(Request $request, Closure $next)
{
$limit = config('app.max_results');
$intLimit = intval($request->input('limit'));
if (abs($intLimit) > 0 && $intLimit <= config('app.max_results')) {
$limit = abs($intLimit);
}
app()->instance('api_limit_value', $limit);
if ($request->filled('page') && ! $request->filled('offset')) {
$page = max(1, intval($request->input('page')));
$offset = ($page - 1) * $limit;
} else {
$offset = intval($request->input('offset'));
$page = $limit > 0 ? (int) floor($offset / $limit) + 1 : 1;
}
app()->instance('api_offset_value', $offset);
app()->instance('api_current_page', $page);
return $next($request);
}
}
+40
View File
@@ -2,9 +2,20 @@
namespace App\Http\Requests;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Location;
use App\Models\Maintenance;
use App\Models\User;
use App\Rules\ValidJson;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class FilterRequest extends FormRequest
{
@@ -23,8 +34,37 @@ class FilterRequest extends FormRequest
*/
public function rules(): array
{
$allowedTypes = [
'accessory',
Accessory::class,
'asset',
Asset::class,
'assetmodel',
'assetModel',
'AssetModel',
AssetModel::class,
'component',
Component::class,
'consumable',
Consumable::class,
'license',
License::class,
'licenseseat',
'licenseSeat',
'LicenseSeat',
LicenseSeat::class,
'location',
Location::class,
'maintenance',
Maintenance::class,
'user',
User::class,
];
return [
'filter' => ['nullable', new ValidJson],
'item_type' => ['nullable', Rule::in($allowedTypes)],
'target_type' => ['nullable', Rule::in($allowedTypes)],
];
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ class ItemImportRequest extends FormRequest
$classString = "App\\Importer\\{$class}Importer";
$importer = new $classString($filename);
$import->field_map = request('column-mappings');
$import->created_by = auth()->id();
$import->created_by = $import->created_by ?? auth()->id();
$import->save();
$fieldMappings = [];
+4 -2
View File
@@ -34,6 +34,8 @@ class SaveUserRequest extends FormRequest
'department_id' => 'nullable|integer|exists:departments,id',
'manager_id' => 'nullable|integer|exists:users,id',
'company_id' => ['nullable', 'integer', 'exists:companies,id'],
'company_ids' => 'nullable|array',
'company_ids.*' => 'integer|exists:companies,id',
];
switch ($this->method()) {
@@ -52,13 +54,13 @@ class SaveUserRequest extends FormRequest
$rules['first_name'] = 'required|string|min:1';
$rules['username'] = 'required_unless:ldap_import,1|string|min:1';
$rules['password'] = Setting::passwordComplexityRulesSaving('update').'|confirmed';
$rules['company_id'] = [new UserCannotSwitchCompaniesIfItemsAssigned];
$rules['company_id'] = ['nullable', 'integer', 'exists:companies,id', new UserCannotSwitchCompaniesIfItemsAssigned];
break;
// Save only what's passed
case 'PATCH':
$rules['password'] = Setting::passwordComplexityRulesSaving('update');
$rules['company_id'] = [new UserCannotSwitchCompaniesIfItemsAssigned];
$rules['company_id'] = ['nullable', 'integer', 'exists:companies,id', new UserCannotSwitchCompaniesIfItemsAssigned];
break;
default:
@@ -26,6 +26,7 @@ class AccessoriesTransformer
'id' => $accessory->id,
'name' => e($accessory->name),
'image' => ($accessory->image) ? Storage::disk('public')->url('accessories/'.e($accessory->image)) : null,
'qr_code_url' => route('qr_code/common', ['object_type' => 'accessories', 'id' => $accessory->id]),
'company' => ($accessory->company) ? [
'id' => $accessory->company->id,
'name' => e($accessory->company->name),
@@ -293,6 +293,28 @@ class ActionlogsTransformer
$clean_meta[trans('general.company')] = $clean_meta['company_id'];
unset($clean_meta['company_id']);
}
if (array_key_exists('companies', $clean_meta)) {
// clean_field() JSON-encodes array values into a string (e.g. "[14,15]").
// Decode them back to integer arrays before resolving names.
// Use withoutGlobalScopes so FMCS does not hide companies from the log viewer.
$resolveCompanyNames = function ($rawValue): string {
$ids = json_decode($rawValue, true);
if (empty($ids) || ! is_array($ids)) {
return trans('general.unassigned');
}
return collect($ids)
->map(fn ($id) => Company::withoutGlobalScopes()->withTrashed()->find($id))
->map(fn ($c) => $c ? e($c->name) : trans('general.deleted'))
->join(', ');
};
$clean_meta['companies']['old'] = $resolveCompanyNames($clean_meta['companies']['old']);
$clean_meta['companies']['new'] = $resolveCompanyNames($clean_meta['companies']['new']);
$clean_meta[trans('general.companies')] = $clean_meta['companies'];
unset($clean_meta['companies']);
}
if (array_key_exists('supplier_id', $clean_meta)) {
$oldSupplier = $supplier->find($clean_meta['supplier_id']['old']);
@@ -48,6 +48,7 @@ class AssetModelsTransformer
'tag_color' => ($assetmodel->manufacturer->tag_color) ? e($assetmodel->manufacturer->tag_color) : null,
] : null,
'image' => ($assetmodel->image != '') ? Storage::disk('public')->url('models/'.e($assetmodel->image)) : null,
'qr_code_url' => route('qr_code/common', ['object_type' => 'models', 'id' => $assetmodel->id]),
'model_number' => ($assetmodel->model_number ? e($assetmodel->model_number) : null),
'min_amt' => ($assetmodel->min_amt) ? (int) $assetmodel->min_amt : null,
+4 -3
View File
@@ -98,6 +98,7 @@ class AssetsTransformer
'tag_color' => ($asset->defaultLoc->tag_color) ? e($asset->defaultLoc->tag_color) : null,
] : null,
'image' => ($asset->getImageUrl()) ? $asset->getImageUrl() : null,
'qr_code_url' => route('qr_code/common', ['object_type' => 'hardware', 'id' => $asset->id]),
'qr' => ($setting->qr_code == '1') ? Storage::disk('public')->url('barcodes/qr-'.str_slug($asset->asset_tag).'-'.str_slug($asset->id).'.png') : null,
'alt_barcode' => ($setting->alt_barcode_enabled == '1') ? Storage::disk('public')->url('barcodes/'.str_slug($setting->alt_barcode).'-'.str_slug($asset->asset_tag).'.png') : null,
'assigned_to' => $this->transformAssignedTo($asset),
@@ -144,7 +145,7 @@ class AssetsTransformer
$fields_array[$field->name] = [
'field' => e($field->db_column),
'value' => e($value),
'value' => ($field->element == 'markdown-textarea' && Gate::allows('assets.view.encrypted_custom_fields')) ? Helper::renderMarkdown($value) : e($value),
'field_format' => $field->format,
'element' => $field->element,
];
@@ -158,7 +159,7 @@ class AssetsTransformer
$fields_array[$field->name] = [
'field' => e($field->db_column),
'value' => e($value),
'value' => ($field->element == 'markdown-textarea') ? Helper::renderMarkdown($value) : e($value),
'field_format' => $field->format,
'element' => $field->element,
];
@@ -274,7 +275,7 @@ class AssetsTransformer
$value = Helper::getFormattedDateObject($value, 'date', false);
}
$fields_array[$field->db_column] = e($value);
$fields_array[$field->db_column] = ($field->element == 'markdown-textarea') ? Helper::renderMarkdown($value) : e($value);
}
$array['custom_fields'] = $fields_array;
@@ -30,6 +30,7 @@ class CompaniesTransformer
'fax' => ($company->fax != '') ? e($company->fax) : null,
'email' => ($company->email != '') ? e($company->email) : null,
'image' => ($company->image) ? Storage::disk('public')->url('companies/'.e($company->image)) : null,
'qr_code_url' => route('qr_code/common', ['object_type' => 'companies', 'id' => $company->id]),
'assets_count' => (int) $company->assets_count,
'licenses_count' => (int) $company->licenses_count,
'accessories_count' => (int) $company->accessories_count,
@@ -26,6 +26,7 @@ class ComponentsTransformer
'id' => (int) $component->id,
'name' => e($component->name),
'image' => ($component->image) ? Storage::disk('public')->url('components/'.e($component->image)) : null,
'qr_code_url' => route('qr_code/common', ['object_type' => 'components', 'id' => $component->id]),
'serial' => ($component->serial) ? e($component->serial) : null,
'location' => ($component->location) ? [
'id' => (int) $component->location->id,
@@ -25,6 +25,7 @@ class ConsumablesTransformer
'id' => (int) $consumable->id,
'name' => e($consumable->name),
'image' => ($consumable->getImageUrl()) ? ($consumable->getImageUrl()) : null,
'qr_code_url' => route('qr_code/common', ['object_type' => 'consumables', 'id' => $consumable->id]),
'category' => ($consumable->category) ? [
'id' => $consumable->category->id,
'name' => e($consumable->category->name),
@@ -38,13 +38,11 @@ class LicenseSeatsTransformer
'tag_color' => $seat->user->department->tag_color ? e($seat->user->department->tag_color) : null,
] : null,
'company' => ($seat->user->company) ?
[
'id' => (int) $seat->user->company->id,
'name' => e($seat->user->company->name),
'tag_color' => $seat->user->company->tag_color ? e($seat->user->company->tag_color) : null,
] : null,
'companies' => $seat->user->companies->map(fn ($c) => [
'id' => (int) $c->id,
'name' => e($c->name),
'tag_color' => $c->tag_color ? e($c->tag_color) : null,
])->values(),
'created_at' => Helper::getFormattedDateObject($seat->created_at, 'datetime'),
] : null,
'assigned_asset' => ($seat->asset) ? [
@@ -24,6 +24,7 @@ class LicensesTransformer
$array = [
'id' => (int) $license->id,
'name' => e($license->name),
'qr_code_url' => route('qr_code/common', ['object_type' => 'licenses', 'id' => $license->id]),
'company' => ($license->company) ? ['id' => (int) $license->company->id, 'name' => e($license->company->name)] : null,
'manufacturer' => ($license->manufacturer) ? [
'id' => (int) $license->manufacturer->id,
@@ -39,6 +39,7 @@ class LocationsTransformer
'id' => (int) $location->id,
'name' => e($location->name),
'image' => ($location->image) ? Storage::disk('public')->url('locations/'.e($location->image)) : null,
'qr_code_url' => route('qr_code/common', ['object_type' => 'locations', 'id' => $location->id]),
'address' => ($location->address) ? e($location->address) : null,
'address2' => ($location->address2) ? e($location->address2) : null,
'city' => ($location->city) ? e($location->city) : null,
+16 -5
View File
@@ -21,7 +21,6 @@ class UsersTransformer
public function transformUser(User $user)
{
$role = null;
if ($user->isSuperUser()) {
$role = 'superadmin';
@@ -31,6 +30,7 @@ class UsersTransformer
$array = [
'id' => (int) $user->id,
'avatar' => e($user->present()->gravatar) ?? null,
'qr_code_url' => route('qr_code/common', ['object_type' => 'users', 'id' => $user->id]),
'name' => e($user->getFullNameAttribute()) ?? null,
'first_name' => e($user->first_name) ?? null,
'last_name' => e($user->last_name) ?? null,
@@ -82,11 +82,17 @@ class UsersTransformer
'consumables_count' => (int) $user->consumables_count,
'manages_users_count' => (int) $user->manages_users_count,
'manages_locations_count' => (int) $user->manages_locations_count,
'company' => ($user->company) ? [
'id' => (int) $user->company->id,
'name' => e($user->company->name),
'tag_color' => ($user->company->tag_color) ? e($user->company->tag_color) : null,
// Legacy field — kept for backward API compatibility; use `companies` for multi-company support.
'company' => $user->companies->isNotEmpty() ? [
'id' => (int) $user->companies->first()->id,
'name' => e($user->companies->first()->name),
'tag_color' => ($user->companies->first()->tag_color) ? e($user->companies->first()->tag_color) : null,
] : null,
'companies' => $user->companies->map(fn ($c) => [
'id' => (int) $c->id,
'name' => e($c->name),
'tag_color' => $c->tag_color ? e($c->tag_color) : null,
])->values(),
'created_by' => ($user->createdBy) ? [
'id' => (int) $user->createdBy->id,
'name' => e($user->createdBy->display_name),
@@ -144,6 +150,11 @@ class UsersTransformer
'last_name' => e($user->last_name),
'username' => e($user->username),
'display_name' => e($user->display_name),
'companies' => $user->companies->map(fn ($c) => [
'id' => (int) $c->id,
'name' => e($c->name),
'tag_color' => $c->tag_color ? e($c->tag_color) : null,
])->values(),
'created_by' => $user->adminuser ? [
'id' => (int) $user->adminuser->id,
'name' => e($user->adminuser->present()->fullName),
+18 -9
View File
@@ -111,7 +111,7 @@ class AssetImporter extends ItemImporter
}
$this->item['notes'] = trim($this->findCsvMatch($row, 'asset_notes'));
$this->item['image'] = trim($this->findCsvMatch($row, 'image'));
$this->item['image'] = basename(trim($this->findCsvMatch($row, 'image')));
$this->item['requestable'] = trim(($this->fetchHumanBoolean($this->findCsvMatch($row, 'requestable'))) == 1) ? '1' : 0;
$asset->requestable = $this->item['requestable'];
$this->item['warranty_months'] = intval(trim($this->findCsvMatch($row, 'warranty_months')));
@@ -214,16 +214,25 @@ class AssetImporter extends ItemImporter
// -- the class that needs to use it (command importer or GUI importer inside the project).
if (isset($target) && ($target !== false)) {
$asset = $asset->fresh();
$targetType = get_class($target);
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
// Skip duplicate checkout noise when update mode keeps the same assignment target.
if (! $alreadyCheckedOutToTarget) {
if (! is_null($asset->assigned_to)) {
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
if (! $asset->canCheckoutTo($target)) {
$this->log(trans('general.error_checkout_company_mismatch', [
'item' => trans('general.asset').' "'.$asset->display_name.'"',
'item_company' => $asset->company?->name ?? trans('general.unassigned'),
'target' => ($target->name ?? $target->username ?? $target->id),
]));
} else {
$targetType = get_class($target);
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
// Skip duplicate checkout noise when update mode keeps the same assignment target.
if (! $alreadyCheckedOutToTarget) {
if (! is_null($asset->assigned_to)) {
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
}
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
}
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
}
}
+15 -7
View File
@@ -59,13 +59,21 @@ class ComponentImporter extends ItemImporter
// If we have an asset tag, checkout to that asset.
if (isset($this->item['asset_tag']) && ($asset = Asset::where('asset_tag', $this->item['asset_tag'])->first())) {
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_by' => auth()->id(),
'created_at' => date('Y-m-d H:i:s'),
'assigned_qty' => 1, // Only assign the first one to the asset
'asset_id' => $asset->id,
]);
if (! $component->canCheckoutTo($asset)) {
$this->log(trans('general.error_checkout_company_mismatch', [
'item' => trans('general.component').' "'.$component->name.'"',
'item_company' => $component->company?->name ?? trans('general.unassigned'),
'target' => trans('general.asset').' "'.$asset->display_name.'"',
]));
} else {
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_by' => auth()->id(),
'created_at' => date('Y-m-d H:i:s'),
'assigned_qty' => 1, // Only assign the first one to the asset
'asset_id' => $asset->id,
]);
}
}
return;
+1
View File
@@ -82,6 +82,7 @@ class ItemImporter extends Importer
$this->item['qty'] = $this->findCsvMatch($row, 'quantity');
$this->item['requestable'] = $this->findCsvMatch($row, 'requestable');
$this->item['created_by'] = auth()->id();
$this->item['asset_tag'] = $this->findCsvMatch($row, 'asset_tag');
$this->item['serial'] = $this->findCsvMatch($row, 'serial');
$this->item['item_no'] = trim($this->findCsvMatch($row, 'item_no'));
+24 -8
View File
@@ -106,16 +106,32 @@ class LicenseImporter extends ItemImporter
}
if ($checkout_target) {
$targetLicense->assigned_to = $checkout_target->id;
$targetLicense->created_by = auth()->id();
if ($asset) {
$targetLicense->asset_id = $asset->id;
if (! $license->canCheckoutTo($checkout_target)) {
$this->log(trans('general.error_checkout_company_mismatch', [
'item' => trans('general.license').' "'.$license->name.'"',
'item_company' => $license->company?->name ?? trans('general.unassigned'),
'target' => ($checkout_target->name ?? $checkout_target->username ?? $checkout_target->id),
]));
} else {
$targetLicense->assigned_to = $checkout_target->id;
$targetLicense->created_by = auth()->id();
if ($asset) {
$targetLicense->asset_id = $asset->id;
}
$targetLicense->save();
}
$targetLicense->save();
} elseif ($asset) {
$targetLicense->created_by = auth()->id();
$targetLicense->asset_id = $asset->id;
$targetLicense->save();
if (! $license->canCheckoutTo($asset)) {
$this->log(trans('general.error_checkout_company_mismatch', [
'item' => trans('general.license').' "'.$license->name.'"',
'item_company' => $license->company?->name ?? trans('general.unassigned'),
'target' => trans('general.asset').' "'.$asset->display_name.'"',
]));
} else {
$targetLicense->created_by = auth()->id();
$targetLicense->asset_id = $asset->id;
$targetLicense->save();
}
}
}
+64 -6
View File
@@ -3,6 +3,7 @@
namespace App\Importer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Department;
use App\Models\Setting;
use App\Models\User;
@@ -35,6 +36,31 @@ class UserImporter extends ItemImporter
$this->createUserIfNotExists($row);
}
/**
* Parse a pipe-separated company column value into an array of company IDs,
* creating companies that do not yet exist. Returns an empty array when the
* raw value is blank (so callers can treat that as "don't change").
*
* @param string $raw Raw cell value, e.g. "Acme Corp|Widget Inc"
* @return int[]
*/
private function resolveCompanyIds(string $raw): array
{
if ($raw === '') {
return [];
}
$ids = [];
foreach (array_filter(array_map('trim', explode('|', $raw))) as $name) {
$id = $this->createOrFetchCompany($name);
if ($id) {
$ids[] = (int) $id;
}
}
return Company::getIdsForCurrentUser($ids);
}
/**
* Create a user if a duplicate does not exist.
*
@@ -49,7 +75,7 @@ class UserImporter extends ItemImporter
// Pull the records from the CSV to determine their values
$this->item['id'] = trim($this->findCsvMatch($row, 'id'));
$this->item['username'] = trim($this->findCsvMatch($row, 'username'));
$this->item['display_name'] = trim($this->findCsvMatch($row, 'display_name'));
$this->item['display_name'] = trim($this->findCsvMatch($row, 'display_name')) ?: null;
$this->item['first_name'] = trim($this->findCsvMatch($row, 'first_name'));
$this->item['last_name'] = trim($this->findCsvMatch($row, 'last_name'));
$this->item['email'] = trim($this->findCsvMatch($row, 'email'));
@@ -80,6 +106,13 @@ class UserImporter extends ItemImporter
$this->item['department_id'] = $this->createOrFetchDepartment($user_department);
}
// Resolve pipe-separated company names (e.g. "Acme Corp|Widget Inc") into IDs.
// company_id is a legacy column — company membership is managed via the pivot.
// Unset whatever the parent set so it is not written to the DB.
$companyRaw = trim($this->findCsvMatch($row, 'company'));
$companyIds = $this->resolveCompanyIds($companyRaw);
unset($this->item['company_id']);
if (is_null($this->item['username']) || $this->item['username'] == '') {
$user_full_name = $this->item['first_name'].' '.$this->item['last_name'];
$user_formatted_array = User::generateFormattedNameFromFullName($user_full_name, Setting::getSettings()->username_format);
@@ -104,11 +137,13 @@ class UserImporter extends ItemImporter
$this->log('Updating User');
if (Auth::check() && (! Gate::allows('canEditAuthFields', $user))) {
unset($user->username);
unset($user->email);
unset($user->password);
unset($user->activated);
// CLI imports run unauthenticated and are fully trusted; only restrict web-initiated imports.
// Note: unset must target $this->item, not the model — sanitizeItemForUpdating() reads from $this->item.
if (Auth::check() && (! Auth::user()->hasAccess('users.edit') || ! Gate::allows('canEditAuthFields', $user))) {
unset($this->item['username']);
unset($this->item['email']);
unset($this->item['password']);
unset($this->item['activated']);
}
$user->update($this->sanitizeItemForUpdating($user));
@@ -116,6 +151,11 @@ class UserImporter extends ItemImporter
// Why do we have to do this twice? Update should
$user->save();
// Sync company pivot when companies were specified in this row.
if (! empty($companyIds)) {
$user->companies()->sync($companyIds);
}
// Update the location of any assets checked out to this user
Asset::where('assigned_type', User::class)
->where('assigned_to', $user->id)
@@ -125,6 +165,17 @@ class UserImporter extends ItemImporter
return;
}
// With FMCS enabled, the scoped lookup above only sees users in the current user's companies.
// If the username exists in another company it would appear as "not found" and fall through
// to create — but usernames are unique system-wide, so we must skip instead.
if (Auth::check() && Company::isFullMultipleCompanySupportEnabled()) {
if (User::withoutGlobalScopes()->where('username', $this->item['username'])->exists()) {
$this->log('Skipping '.$this->item['username'].': username belongs to a user outside your company scope.');
return;
}
}
// This needs to be applied after the update logic, otherwise we'll overwrite user passwords
// Issue #5408
$this->item['password'] = $this->tempPassword;
@@ -140,6 +191,13 @@ class UserImporter extends ItemImporter
if ($user->save()) {
$this->log('User '.$this->item['name'].' was created');
// Sync all resolved companies to the pivot. For single-company rows the
// User::created event already added company_id; sync() here is idempotent
// for that case and adds any additional companies for multi-company rows.
if (! empty($companyIds)) {
$user->companies()->sync($companyIds);
}
if (($user->email) && ($user->activated == '1')) {
if ($this->send_welcome) {
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Mail;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class BulkDeleteReportMail extends BaseMailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly User $admin,
public readonly bool $dryRun,
public readonly array $companyNames,
public readonly array $selectedTypes,
public readonly string $deleteType,
public readonly array $reportLines,
public readonly Carbon $runAt,
) {}
public function envelope(): Envelope
{
$subject = $this->dryRun
? '[Dry Run] Bulk Check-in/Delete Report'
: 'Bulk Check-in/Delete Report';
return new Envelope(
from: new Address(config('mail.from.address'), config('mail.from.name')),
subject: $subject,
);
}
public function content(): Content
{
return new Content(
markdown: 'notifications.markdown.report-bulk-delete',
with: [
'admin' => $this->admin,
'dryRun' => $this->dryRun,
'companyNames' => $this->companyNames,
'selectedTypes' => $this->selectedTypes,
'deleteType' => $this->deleteType,
'reportLines' => $this->reportLines,
'runAt' => $this->runAt,
],
);
}
public function attachments(): array
{
return [];
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ class Accessory extends SnipeModel
'name' => 'required|max:255',
'qty' => 'nullable|integer|min:0',
'category_id' => 'required|integer|exists:categories,id',
'company_id' => 'integer|nullable',
'company_id' => 'integer|nullable|exists:companies,id',
'location_id' => 'exists:locations,id|nullable|fmcs_location',
'min_amt' => 'integer|min:0|nullable',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
+2 -1
View File
@@ -146,7 +146,8 @@ class AccessoryCheckout extends Model
$search_str = '%'.$term.'%';
$query->where('first_name', 'like', $search_str)
->orWhere('last_name', 'like', $search_str)
->orWhere('note', 'like', $search_str);
->orWhere('note', 'like', $search_str)
->orWhereHas('companies', fn ($q) => $q->where('companies.name', 'like', $search_str));
}
}
)->select('id');
+38 -50
View File
@@ -34,7 +34,7 @@ class Asset extends Depreciable
{
protected $presenter = AssetPresenter::class;
protected $with = ['model', 'adminuser', 'location', 'company'];
// protected $with = ['model', 'adminuser', 'location', 'company'];
use CompanyableTrait;
use HasFactory;
@@ -487,16 +487,18 @@ class Asset extends Depreciable
public function availableForCheckIn()
{
// This asset is currently assigned to anyone and is not deleted...
if (($this->assigned_to != '') && ($this->status) && ($this->status->archived == '0')
&& ($this->status->deployable == '1')
) {
return true;
if ($this->assigned_to == '') {
return false;
}
return false;
// Deleted assets that are still checked out should always allow checkin
if ($this->deleted_at != '') {
return true;
}
return $this->status
&& ($this->status->archived == '0')
&& ($this->status->deployable == '1');
}
/**
@@ -1480,13 +1482,10 @@ class Asset extends Depreciable
*/
public function scopePending($query)
{
return $query->whereHas(
'status', function ($query) {
$query->where('deployable', '=', 0)
->where('pending', '=', 1)
->where('archived', '=', 0);
}
);
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
$ids = Statuslabel::where('deployable', 0)->where('pending', 1)->where('archived', 0)->whereNull('deleted_at')->pluck('id');
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
}
/**
@@ -1536,14 +1535,11 @@ class Asset extends Depreciable
*/
public function scopeRTD($query)
{
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
$ids = Statuslabel::where('deployable', 1)->where('pending', 0)->where('archived', 0)->whereNull('deleted_at')->pluck('id');
return $query->whereNull('assets.assigned_to')
->whereHas(
'status', function ($query) {
$query->where('deployable', '=', 1)
->where('pending', '=', 0)
->where('archived', '=', 0);
}
);
->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
}
/**
@@ -1554,13 +1550,10 @@ class Asset extends Depreciable
*/
public function scopeUndeployable($query)
{
return $query->whereHas(
'status', function ($query) {
$query->where('deployable', '=', 0)
->where('pending', '=', 0)
->where('archived', '=', 0);
}
);
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
$ids = Statuslabel::where('deployable', 0)->where('pending', 0)->where('archived', 0)->whereNull('deleted_at')->pluck('id');
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
}
/**
@@ -1571,11 +1564,10 @@ class Asset extends Depreciable
*/
public function scopeNotArchived($query)
{
return $query->whereHas(
'status', function ($query) {
$query->where('archived', '=', 0);
}
);
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
$ids = Statuslabel::where('archived', 0)->whereNull('deleted_at')->pluck('id');
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
}
/**
@@ -1738,17 +1730,16 @@ class Asset extends Depreciable
*/
public function scopeAssetsForShow($query)
{
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
if (Setting::getSettings()->show_archived_in_list != 1) {
return $query->whereHas(
'status', function ($query) {
$query->where('archived', '=', 0);
}
);
} else {
return $query;
$validStatusIds = Statuslabel::where('archived', 0)
->whereNull('deleted_at')
->pluck('id');
return $query->whereIn('assets.status_id', $validStatusIds->isEmpty() ? [0] : $validStatusIds);
}
return $query;
}
/**
@@ -1759,13 +1750,10 @@ class Asset extends Depreciable
*/
public function scopeArchived($query)
{
return $query->whereHas(
'status', function ($query) {
$query->where('deployable', '=', 0)
->where('pending', '=', 0)
->where('archived', '=', 1);
}
);
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
$ids = Statuslabel::where('deployable', 0)->where('pending', 0)->where('archived', 1)->whereNull('deleted_at')->pluck('id');
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
}
/**
+183 -27
View File
@@ -11,9 +11,11 @@ use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Watson\Validating\ValidatingTrait;
/**
@@ -94,7 +96,26 @@ final class Company extends SnipeModel
'notes',
];
private static function isFullMultipleCompanySupportEnabled()
/**
* Return the current user's company IDs by querying the pivot table directly.
*
* We deliberately bypass the Eloquent companies() relationship here because
* loading that relationship triggers CompanyableScope on the Company model,
* which calls this method again infinite recursion.
*/
private static function getCurrentUserCompanyIds(): array
{
if (! Auth::hasUser()) {
return [];
}
return DB::table('company_user')
->where('user_id', auth()->id())
->pluck('company_id')
->toArray();
}
public static function isFullMultipleCompanySupportEnabled()
{
$settings = Setting::getSettings();
@@ -135,11 +156,27 @@ final class Company extends SnipeModel
if ($current_user->isSuperUser()) {
return self::getIdFromInput($unescaped_input);
} else {
if ($current_user->company_id != null) {
return $current_user->company_id;
} else {
return null;
$userCompanyIds = self::getCurrentUserCompanyIds();
$submittedId = (int) self::getIdFromInput($unescaped_input);
// Company membership is now determined entirely by the pivot (company_user table).
// If the submitted value is a company the user actually belongs to, honour it.
if ($submittedId && in_array($submittedId, $userCompanyIds)) {
return $submittedId;
}
// A user with pivot memberships who submits a company they don't belong to is
// attempting cross-tenant assignment — reject outright rather than silently
// overriding or storing null.
if ($submittedId && ! empty($userCompanyIds)) {
throw ValidationException::withMessages([
'company_id' => [trans('validation.in', ['attribute' => 'company_id'])],
]);
}
// No company submitted (or user has no pivot memberships) — fall back to the
// user's single company if unambiguous, otherwise null.
return count($userCompanyIds) === 1 ? $userCompanyIds[0] : null;
}
}
}
@@ -179,20 +216,65 @@ final class Company extends SnipeModel
}
if (auth()->user()) {
// Log::warning('Companyable is '.$companyable);
$current_user_company_id = auth()->user()->company_id;
$companyable_company_id = $companyable->company_id;
// Set this to check companyable on company
if ($companyable instanceof Company) {
$companyable_company_id = $companyable->id;
if (auth()->user()->isSuperUser()) {
return true;
}
return ($current_user_company_id == null) || ($current_user_company_id == $companyable_company_id) || auth()->user()->isSuperUser();
$userCompanyIds = self::getCurrentUserCompanyIds();
// Empty pivot = unrestricted only for true legacy "no-company" users
// (those whose scalar company_id is also null). Users who had their
// pivot cleared via the API retain their scalar company_id, so they
// do NOT qualify for this bypass.
if (empty($userCompanyIds) && is_null(auth()->user()->company_id)) {
return true;
}
// Users are scoped by pivot membership, not company_id, so check the pivot directly.
if ($companyable instanceof User) {
$companyableCompanyIds = DB::table('company_user')
->where('user_id', $companyable->id)
->pluck('company_id')
->toArray();
// A user with no pivot rows is a null-company user; no intersection is possible.
if (empty($companyableCompanyIds)) {
return false;
}
return ! empty(array_intersect($userCompanyIds, $companyableCompanyIds));
}
$companyable_company_id = ($companyable instanceof Company)
? $companyable->id
: $companyable->company_id;
return in_array($companyable_company_id, $userCompanyIds);
}
return false;
}
/**
* Filter an array of requested company IDs to only those the current user
* belongs to. Superusers may assign any company; non-superusers are limited
* to their own pivot memberships when FMCS is enabled.
*/
public static function getIdsForCurrentUser(array $requestedIds): array
{
if (! self::isFullMultipleCompanySupportEnabled()) {
return $requestedIds;
}
$current_user = auth()->user();
if ($current_user->isSuperUser()) {
return $requestedIds;
}
$allowedIds = self::getCurrentUserCompanyIds();
return array_values(array_intersect($requestedIds, $allowedIds));
}
public static function isCurrentUserAuthorized()
@@ -202,8 +284,9 @@ final class Company extends SnipeModel
public static function canManageUsersCompanies()
{
return ! self::isFullMultipleCompanySupportEnabled() || auth()->user()->isSuperUser() ||
auth()->user()->company_id == null;
return ! self::isFullMultipleCompanySupportEnabled()
|| auth()->user()->isSuperUser()
|| ! empty(self::getCurrentUserCompanyIds());
}
/**
@@ -242,7 +325,7 @@ final class Company extends SnipeModel
public function users()
{
return $this->hasMany(User::class, 'company_id');
return $this->belongsToMany(User::class, 'company_user');
}
public function assets()
@@ -304,27 +387,100 @@ final class Company extends SnipeModel
*/
private static function scopeCompanyablesDirectly($query, $column = 'company_id', $table_name = null)
{
$company_id = null;
// Get the company ID of the logged-in user, or set it to null if there is no company associated with the user
if (Auth::hasUser()) {
$company_id = auth()->user()->company_id;
}
$companyIds = self::getCurrentUserCompanyIds();
// If we are scoping the companies table itself, look for the company.id
if ($query->getModel()->getTable() == 'companies') {
return $query->where('companies.id', '=', $company_id);
if (empty($companyIds)) {
return $query->whereNull('companies.id');
}
return $query->whereIn('companies.id', $companyIds);
}
$floater = Setting::getSettings()->null_company_is_floater;
// Users are scoped by pivot membership (company_user), not by company_id column,
// since a user may belong to multiple companies and company_id alone is insufficient.
if ($query->getModel()->getTable() == 'users') {
if (empty($companyIds)) {
// Floater: actor has no company and is unrestricted — see everyone.
if ($floater) {
return $query;
}
// No pivot memberships: mirror old null-company behavior — show only users
// who are also not in any company via the pivot.
return $query->whereNotIn('users.id', function ($sub) {
$sub->select('user_id')->from('company_user');
});
}
// Floater: also include users with no company associations (they float). They all float down here, Georgie.).
if ($floater) {
return $query->where(function ($q) use ($companyIds) {
$q->whereIn('users.id', function ($sub) use ($companyIds) {
$sub->select('user_id')->from('company_user')->whereIn('company_id', $companyIds);
})->orWhereDoesntHave('companies');
});
}
return $query->whereIn('users.id', function ($sub) use ($companyIds) {
$sub->select('user_id')->from('company_user')->whereIn('company_id', $companyIds);
});
}
// If the column exists in the table, use it to scope the query
if ((($query) && ($query->getModel()) && (Schema::hasColumn($query->getModel()->getTable(), $column)))) {
// Dynamically get the table name if it's not passed in, based on the model we're querying against
if ($query && $query->getModel() && Schema::hasColumn($query->getModel()->getTable(), $column)) {
$table = ($table_name) ? $table_name.'.' : $query->getModel()->getTable().'.';
return $query->where($table.$column, '=', $company_id);
if (empty($companyIds)) {
// Floater: actor has no company and is unrestricted — see everything.
if ($floater) {
return $query;
}
return $query->whereNull($table.$column);
}
// action_logs: a NULL company_id means the logged object (AssetModel, Company, etc.)
// has no company_id column of its own. Those are global objects, visible to all users,
// so their log entries should not be hidden by the company filter.
if ($query->getModel()->getTable() === 'action_logs') {
return $query->where(function ($q) use ($table, $column, $companyIds) {
$q->whereIn($table.$column, $companyIds)
->orWhereNull($table.$column);
});
}
// Floater: null-company items are visible to users from any company.
if ($floater) {
return $query->where(function ($q) use ($table, $column, $companyIds) {
$q->whereIn($table.$column, $companyIds)
->orWhereNull($table.$column);
});
}
return $query->whereIn($table.$column, $companyIds);
}
}
/**
* Scope a users query to those belonging to the given company IDs, respecting floater mode.
*
* Extracted from controller-level inline logic so the same rule is enforced consistently
* everywhere users are filtered by a specific set of company IDs (e.g. select2 dropdowns).
*/
public static function scopeUsersByCompanyIds($query, array $companyIds): mixed
{
if (Setting::getSettings()->null_company_is_floater) {
return $query->where(function ($q) use ($companyIds) {
$q->whereHas('companies', fn ($q2) => $q2->whereIn('companies.id', $companyIds))
->orWhereDoesntHave('companies');
});
}
return $query->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
}
/**
+1 -1
View File
@@ -48,7 +48,7 @@ class Consumable extends SnipeModel
'name' => 'required|max:255',
'qty' => 'required|integer|min:0|max:99999',
'category_id' => 'required|integer',
'company_id' => 'integer|nullable',
'company_id' => 'integer|nullable|exists:companies,id',
'location_id' => 'exists:locations,id|nullable|fmcs_location',
'min_amt' => 'integer|min:0|max:99999|nullable',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
+1 -1
View File
@@ -51,7 +51,7 @@ class CustomField extends Model
*/
protected $rules = [
'name' => 'required|unique:custom_fields',
'element' => 'required|in:text,listbox,textarea,checkbox,radio',
'element' => 'required|in:text,listbox,textarea,markdown-textarea,checkbox,radio',
'field_encrypted' => 'nullable|boolean',
'auto_add_to_fieldsets' => 'boolean',
'show_in_listview' => 'boolean',
+13 -7
View File
@@ -3,6 +3,7 @@
namespace App\Models\Labels;
use App\Models\Asset;
use App\Models\User;
class FieldOption
{
@@ -27,14 +28,19 @@ class FieldOption
// assignedTo directly on the asset is a special case where
// we want to avoid returning the property directly
// and instead return the entity's presented name.
if ($dataPath[0] === 'assignedTo') {
if ($asset->relationLoaded('assignedTo')) {
// If the "assignedTo" relationship was eager loaded then the way to get the
// relationship changes from $asset->assignedTo to $asset->assigned.
return $asset->assigned ? $asset->assigned->display_name : null;
}
if (in_array($dataPath[0], ['assignedTo', 'displayName'])) {
$assigned = $asset->relationLoaded('assignedTo') ? $asset->assigned : $asset->assignedTo;
return $asset->assignedTo ? $asset->assignedTo->display_name : null;
if (!$assigned) {
return null;
}
if ($dataPath[0] === 'displayName') {
return $assigned->getRawOriginal('display_name') ?? $assigned->display_name;
}
if ($assigned instanceof User) {
return $assigned->full_name;
}
return $assigned->name ?? $assigned->display_name ?? null;
}
// Handle Laravel's stupid Carbon datetime casting
+3 -2
View File
@@ -59,7 +59,7 @@ class License extends Depreciable
'license_name' => 'string|nullable|max:100',
'notes' => 'string|nullable',
'category_id' => 'required|exists:categories,id',
'company_id' => 'integer|nullable',
'company_id' => 'integer|nullable|exists:companies,id',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'purchase_date' => 'date_format:Y-m-d|nullable|max:10|required_with:depreciation_id',
'expiration_date' => 'date_format:Y-m-d|nullable|max:10',
@@ -803,7 +803,7 @@ class License extends Depreciable
*
* @return mixed
*/
public function freeSeat()
public function freeSeat(bool $lock = false)
{
return $this->licenseseats()
->whereNull('deleted_at')
@@ -813,6 +813,7 @@ class License extends Depreciable
->whereNull('asset_id');
})
->orderBy('id', 'asc')
->when($lock, fn ($q) => $q->lockForUpdate())
->first();
}
+6 -7
View File
@@ -170,14 +170,13 @@ class Location extends SnipeModel
*/
public function assets()
{
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
$ids = Statuslabel::where(function ($q) {
$q->where('deployable', 1)->orWhere('pending', 1)->orWhere('archived', 0);
})->whereNull('deleted_at')->pluck('id');
return $this->hasMany(Asset::class, 'location_id')
->whereHas(
'status', function ($query) {
$query->where('status_labels.deployable', '=', 1)
->orWhere('status_labels.pending', '=', 1)
->orWhere('status_labels.archived', '=', 0);
}
);
->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
}
public function countAllTheThings()
+7
View File
@@ -2,6 +2,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class SCIMUser extends User
{
protected $table = 'users';
@@ -21,4 +23,9 @@ class SCIMUser extends User
return $this->belongsToMany(\App\Models\Group::class, 'users_groups', 'user_id', 'group_id');
}
public function companies(): BelongsToMany
{
return $this->belongsToMany(Company::class, 'company_user', 'user_id', 'company_id');
}
}
+35
View File
@@ -3,7 +3,9 @@
namespace App\Models;
use App\Helpers\Helper;
use App\Rules\CssColor;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
@@ -173,6 +175,34 @@ class Setting extends Model
*
* @author A. Gianotto <snipe@snipe.net>
*/
protected function headerColor(): Attribute
{
return Attribute::make(
get: fn (?string $value) => CssColor::sanitize($value, '#3c8dbc'),
);
}
protected function linkLightColor(): Attribute
{
return Attribute::make(
get: fn (?string $value) => CssColor::sanitize($value, '#296282'),
);
}
protected function linkDarkColor(): Attribute
{
return Attribute::make(
get: fn (?string $value) => CssColor::sanitize($value, '#5fa4cc'),
);
}
protected function navLinkColor(): Attribute
{
return Attribute::make(
get: fn (?string $value) => CssColor::sanitize($value, '#ffffff'),
);
}
public function show_custom_css(): string
{
$custom_css = self::getSettings()->custom_css;
@@ -186,6 +216,11 @@ class Setting extends Model
return $custom_css;
}
public function isQrEnabled(): bool
{
return $this->qr_code == '1' || $this->label2_2d_type !== 'none';
}
/**
* Converts bytes into human readable file size.
*
+185 -10
View File
@@ -12,6 +12,7 @@ use ArieTimmerman\Laravel\SCIMServer\Attribute\Meta;
use ArieTimmerman\Laravel\SCIMServer\Attribute\MutableCollection;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Schema as AttributeSchema;
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
use ArieTimmerman\Laravel\SCIMServer\Parser\Parser;
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
use Illuminate\Database\Eloquent\Model;
@@ -31,11 +32,137 @@ function eloquent($name, $attribute = null): Attribute
return new Eloquent($name, $attribute);
}
class EloquentWithRemove extends Eloquent
// Extends Complex to handle schema-qualified attribute keys in PATCH add/replace operations.
// Azure Entra ID sends PATCH without a "path" field, putting the full URN as the value dict key
// e.g. {"op":"add","value":{"urn:...grokability...:location":"Head Office"}}.
// The upstream library's add() only searches the default (core) schema, silently dropping grokability attrs.
class SnipeRootComplex extends Complex
{
public function remove($value, Model &$object, ?Path $path = null)
private function findInSchema(string $schemaUrn, string $attrName): ?object
{
$object->{$this->attribute} = null;
$schemaNode = $this->getSubNode($schemaUrn);
return ($schemaNode instanceof AttributeSchema) ? $schemaNode->getSubNode($attrName) : null;
}
public function add($value, Model &$object)
{
$match = false;
$this->dirty = true;
if ($this->mutability == 'readOnly') {
return;
}
foreach ($value as $key => $v) {
if (is_numeric($key)) {
throw new SCIMException('Invalid key: '.$key.' for complex object '.$this->getFullKey());
}
$path = Parser::parse($key);
if ($path->isNotEmpty()) {
$attributeNames = $path->getAttributePathAttributes();
$schema = $path->getAttributePath()?->path?->schema;
$path = $path->shiftAttributePathAttributes();
$subNode = ($schema !== null) ? $this->findInSchema($schema, $attributeNames[0]) : null;
if ($subNode === null) {
$subNode = $this->getSubNode($attributeNames[0]);
}
$match = true;
$newValue = $v;
if ($path->isNotEmpty()) {
$newValue = [implode('.', $path->getAttributePathAttributes()) => $v];
}
if ($subNode !== null) {
$subNode->add($newValue, $object);
}
}
}
if (! $match && $this->parent == null) {
foreach ($this->subAttributes as $attribute) {
if ($attribute instanceof AttributeSchema) {
$attribute->add($value, $object);
}
}
}
}
public function replace($value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
$this->dirty = true;
if ($this->mutability == 'readOnly') {
return;
}
foreach ($value as $key => $v) {
if (is_numeric($key)) {
throw new SCIMException('Invalid key: '.$key.' for complex object '.$this->getFullKey());
}
$subNode = null;
if (strpos($key, ':') !== false) {
$parsed = Parser::parse($key);
$schemaUrn = $parsed->getAttributePath()?->path?->schema;
$attrName = $parsed->getAttributePathAttributes()[0] ?? null;
if ($schemaUrn !== null && $attrName !== null) {
$subNode = $this->findInSchema($schemaUrn, $attrName);
}
if ($subNode === null) {
$subNode = $this->getSubNode($key);
}
} else {
$path = Parser::parse($key);
if ($path->isNotEmpty()) {
$attributeNames = $path->getAttributePathAttributes();
$path = $path->shiftAttributePathAttributes();
$subNode = $this->getSubNode($attributeNames[0] ?? $path->getAttributePath()?->path?->schema);
}
}
if ($subNode !== null) {
$newValue = $v;
if ($path !== null && $path->isNotEmpty()) {
$newValue = [implode('.', $path->getAttributePathAttributes()) => $v];
}
$subNode->replace($newValue, $object, $path);
}
}
if ($subNode == null && $this->parent == null) {
foreach ($this->subAttributes as $attribute) {
if ($attribute instanceof AttributeSchema) {
$attribute->replace($value, $object, $path);
}
}
}
if ($removeIfNotSet) {
foreach ($this->subAttributes as $attribute) {
if (! $attribute->isDirty()) {
$attribute->remove(null, $object);
}
}
}
}
}
// Azure Entra ID sends op=replace with path=members and only the single user being provisioned,
// not the full member list. Using sync() would wipe all other members on every user update.
// Override replace() to use syncWithoutDetaching() so it behaves like add(); op=remove with a
// filter path still handles explicit removals correctly.
class SnipeMutableCollection extends MutableCollection
{
public function replace($value, Model &$object, ?Path $path = null)
{
$this->add($value, $object);
}
}
@@ -46,8 +173,8 @@ class MappedTable extends Attribute
private string $relationship_name,
private string $relationship_class,
private string $relationship_id_field,
private string $relationship_field)
{
private string $relationship_field
) {
parent::__construct($this->scim_attribute_name);
}
@@ -72,6 +199,50 @@ class MappedTable extends Attribute
}
}
// Company is stored only in the company_user pivot, not company_id. Read from the pivot
// and sync it on write. For new users (not yet saved) defer the sync via a saved() callback.
class SCIMCompanyAttribute extends MappedTable
{
protected function doRead(&$object, $attributes = [])
{
return $object->companies->first()?->name;
}
private function applyCompany(?int $companyId, Model &$object): void
{
$ids = $companyId ? [$companyId] : [];
if ($object->exists) {
$object->companies()->sync($ids);
} else {
$object->saved(fn () => $object->companies()->sync($ids));
}
}
public function add($value, Model &$object)
{
$this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object);
}
public function replace($value, Model &$object, $path = null, $removeIfNotSet = false)
{
$this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object);
}
public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
$this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object);
}
}
class EloquentWithRemove extends Eloquent
{
public function remove($value, Model &$object, ?Path $path = null)
{
$object->{$this->attribute} = null;
}
}
class UpdatableComplex extends Complex
{
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
@@ -132,7 +303,7 @@ class SnipeSCIMConfig
'withRelations' => [],
'description' => 'User Account',
'map' => complex()->withSubAttributes(
'map' => (new SnipeRootComplex)->withSubAttributes(
new class('schemas', ['urn:ietf:params:scim:schemas:core:2.0:User', self::ENTERPRISE, self::GROKABILITY]) extends Constant
{
public function replace($value, &$object, $path = null)
@@ -190,7 +361,11 @@ class SnipeSCIMConfig
{
if ($value) {
try {
$object->email = $value[0]['value'];
if (is_string($value)) {
$object->email = $value; // Weird MS-SCIM stuff :/
} else {
$object->email = $value[0]['value'];
}
} catch (\Throwable $e) {
\Log::debug($e);
throw new SCIMException("Unknown email object: '".print_r($value, true)."'", 422);
@@ -299,7 +474,7 @@ class SnipeSCIMConfig
$address['primary'] = true;
}
return $address;
return [$address];
}
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
@@ -422,7 +597,7 @@ class SnipeSCIMConfig
),
(new AttributeSchema(self::GROKABILITY, false))->withSubAttributes(
new MappedTable('location', 'location', Location::class, 'location_id', 'name'),
new MappedTable('company', 'company', Company::class, 'company_id', 'name'),
new SCIMCompanyAttribute('company', 'company', Company::class, 'company_id', 'name'),
)
),
];
@@ -471,7 +646,7 @@ class SnipeSCIMConfig
$fail('The name has already been taken.');
}
}),
(new MutableCollection('members'))->withSubAttributes(
(new SnipeMutableCollection('members'))->withSubAttributes(
eloquent('value', 'id')->ensure('required'),
(new class('$ref') extends Eloquent
{
+1 -1
View File
@@ -30,7 +30,7 @@ class Supplier extends SnipeModel
'fax' => 'min:7|max:35|nullable',
'phone' => 'min:7|max:35|nullable',
'contact' => 'max:100|nullable',
'notes' => 'max:191|nullable', // Default string length is 191 characters..
'notes' => 'max:16383|nullable', // text is 65535 but each character can take up to 4 bytes in utf8mb4
'email' => 'email|max:150|nullable',
'address' => 'max:250|nullable',
'address2' => 'max:250|nullable',
+39 -8
View File
@@ -2,9 +2,10 @@
namespace App\Models\Traits;
use App\Models\Company\Company;
use App\Models\CompanyableScope;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
trait CompanyableTrait
{
@@ -18,13 +19,43 @@ trait CompanyableTrait
*/
public static function bootCompanyableTrait()
{
// In Version 7.0 and before locations weren't scoped by companies, so add a check for the backward compatibility setting
if (__CLASS__ != 'App\Models\Location') {
static::addGlobalScope(new CompanyableScope);
} else {
if (Setting::getSettings()?->scope_locations_fmcs == 1) {
static::addGlobalScope(new CompanyableScope);
}
static::addGlobalScope(new CompanyableScope);
}
/**
* Whether this item may be checked out to the given target under FMCS rules.
*
* Returns true when:
* - FMCS is disabled, OR
* - this item has no company (uncompanied items are unrestricted), OR
* - target is a User whose company pivot includes this item's company, OR
* - target has no company and null_company_is_floater is enabled, OR
* - target's company_id exactly matches this item's company_id.
*/
public function canCheckoutTo(Model $target): bool
{
$settings = Setting::getSettings();
if (! $settings->full_multiple_companies_support) {
return true;
}
if (! $this->company_id) {
if (is_null($target->company_id)) {
return true;
}
return (bool) $settings->null_company_is_floater;
}
if ($target instanceof User) {
return $target->canReceiveFromCompany((int) $this->company_id);
}
if (is_null($target->company_id)) {
return (bool) $settings->null_company_is_floater;
}
return (int) $target->company_id === (int) $this->company_id;
}
}
+5
View File
@@ -3,12 +3,17 @@
namespace App\Models\Traits;
use App\Models\Actionlog;
use App\Models\CompanyableScope;
trait HasUploads
{
public function uploads()
{
// Bypass FMCS company scoping: access is already gated by the policy on the
// parent object. Objects like AssetModel and Company have no company_id, so
// their upload logs always have company_id = null, which the scope would hide.
return $this->hasMany(Actionlog::class, 'item_id')
->withoutGlobalScope(CompanyableScope::class)
->where('item_type', self::class)
->where('action_type', '=', 'uploaded')
->whereNotNull('filename')
+18 -6
View File
@@ -4,6 +4,7 @@ namespace App\Models\Traits;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CompanyableScope;
use App\Models\ICompanyableChild;
use App\Models\License;
use App\Models\LicenseSeat;
@@ -41,13 +42,15 @@ trait Loggable
public function history()
{
// Bypass FMCS company scoping: access is already gated by the policy on the
// parent object. Objects like AssetModel and Company have no company_id, so
// their history logs always have company_id = null, which the scope would hide.
return $this->morphMany(Actionlog::class, 'item')
->withoutGlobalScope(CompanyableScope::class)
->orWhere(function ($query) {
$query->where('target_type', '=', static::class)
->where('target_id', '=', $this->getKey());
});
}
public function getHistory(Request $request)
@@ -446,7 +449,7 @@ trait Loggable
} catch (ServerException $e) {
Log::error('Teams webhook server error', [
Log::warning('Teams webhook server error', [
'endpoint' => $endpoint,
'status' => $e->getResponse()?->getStatusCode(),
'error' => $e->getMessage(),
@@ -461,19 +464,28 @@ trait Loggable
]);
} catch (RequestException $e) {
Log::error('Teams webhook request failure', [
Log::warning('Teams webhook request failure', [
'endpoint' => $endpoint,
'error' => $e->getMessage(),
]);
} catch (Throwable $e) {
Log::error('Teams webhook failed unexpectedly', [
Log::warning('Teams webhook failed unexpectedly', [
'endpoint' => $endpoint,
'exception' => get_class($e),
'error' => $e->getMessage(),
]);
}
} else {
Setting::getSettings()->notify(new AuditNotification($params));
try {
Setting::getSettings()->notify(new AuditNotification($params));
} catch (Throwable $e) {
Log::warning('Audit webhook notification failed', [
'endpoint' => Setting::getSettings()->webhook_endpoint,
'channel' => Setting::getSettings()->webhook_selected,
'exception' => get_class($e),
'error' => $e->getMessage(),
]);
}
}
return $log;
+173 -28
View File
@@ -9,6 +9,7 @@ use App\Models\Traits\Loggable;
use App\Models\Traits\Searchable;
use App\Presenters\Presentable;
use App\Presenters\UserPresenter;
use App\Rules\CssColor;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
@@ -18,6 +19,7 @@ use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -59,6 +61,13 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
protected $injectUniqueIdentifier = true;
/**
* Transient (non-persisted) ID of the Actionlog entry written by UserObserver::updating()
* during the current request. syncCompaniesWithLogging() merges company changes into this
* entry instead of creating a separate one, so a single edit session produces one log row.
*/
public ?int $currentUpdateLogId = null;
protected $fillable = [
'activated',
'address',
@@ -166,7 +175,7 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
'userloc' => ['name', 'address', 'address2', 'city', 'state', 'zip'],
'department' => ['name'],
'groups' => ['name'],
'company' => ['name'],
'companies' => ['name'],
'manager' => ['first_name', 'last_name', 'username', 'display_name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
@@ -244,6 +253,15 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
protected static function booted(): void
{
// Bridge for factories/seeders that still set company_id directly: ensure
// that company appears in the pivot so FMCS scoping works correctly.
// Application code (controllers, importers) writes only to the pivot.
static::created(function (User $user) {
if ($user->company_id) {
$user->companies()->syncWithoutDetaching([$user->company_id]);
}
});
static::forceDeleted(function (User $user) {
CheckoutRequest::where(['user_id' => $user->id])->forceDelete();
$user->purgeAssociatedPassportTokens();
@@ -305,7 +323,7 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
protected function displayName(): Attribute
{
return Attribute::make(
get: fn (mixed $value) => $value ?? $this->getFullNameAttribute(),
get: fn (mixed $value) => ($value !== null && $value !== '') ? $value : $this->getFullNameAttribute(),
);
}
@@ -583,7 +601,6 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
&& (($this->accessories_count ?? $this->accessories()->count()) === 0)
&& (($this->licenses_count ?? $this->licenses()->count()) === 0)
&& (($this->consumables_count ?? $this->consumables()->count()) === 0)
&& (($this->accessories_count ?? $this->accessories()->count()) === 0)
&& (($this->manages_users_count ?? $this->managesUsers()->count()) === 0)
&& (($this->manages_locations_count ?? $this->managedLocations()->count()) === 0)
&& ($this->deleted_at == '');
@@ -603,6 +620,88 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
return $this->belongsTo(Company::class, 'company_id');
}
public function companies(): BelongsToMany
{
return $this->belongsToMany(Company::class, 'company_user');
}
/**
* Returns whether an FMCS company check should allow this user to receive
* an asset that belongs to the given company.
*
* - If the user has no company associations at all: returns true (no restriction).
* - If the user has associations: returns true only when $companyId is among them.
*/
public function canReceiveFromCompany(int $companyId): bool
{
// Items with no company association are unrestricted — anyone can receive them.
if (! $companyId) {
return true;
}
// Query the pivot directly to avoid the Company model's FMCS global scope,
// which would restrict results to the current actor's visible companies.
$userCompanyIds = DB::table('company_user')
->where('user_id', $this->id)
->pluck('company_id');
if ($userCompanyIds->isEmpty()) {
return (bool) Setting::getSettings()->null_company_is_floater;
}
return $userCompanyIds->contains($companyId);
}
/**
* Returns all companies this user belongs to union of the primary company_id
* column and the many-to-many pivot as a deduplicated Collection.
* Used to scope FMCS dropdowns to companies the user is allowed to work with.
*/
public function allCompanies(): Collection
{
return $this->companies->unique('id')->values();
}
/**
* Sync company pivot membership and log the change if the set of companies changed.
*
* When called after $user->save() in the same request, UserObserver::updating() will
* have already written an Actionlog row and stored its ID in $this->currentUpdateLogId.
* In that case we merge the company change into that existing entry so that a single
* edit session (field changes + company changes) produces one log row, not two.
*/
public function syncCompaniesWithLogging(array $companyIds): void
{
$oldIds = $this->companies()->orderBy('companies.id')->pluck('companies.id')->toArray();
$this->companies()->sync($companyIds);
$newIds = $this->companies()->orderBy('companies.id')->pluck('companies.id')->toArray();
if ($oldIds === $newIds) {
return;
}
$companyChange = ['companies' => ['old' => $oldIds, 'new' => $newIds]];
if ($this->currentUpdateLogId && ($existing = Actionlog::find($this->currentUpdateLogId))) {
$meta = json_decode($existing->log_meta ?? '{}', true) ?: [];
$existing->log_meta = json_encode(array_merge($meta, $companyChange));
$existing->save();
$this->currentUpdateLogId = null;
return;
}
$logAction = new Actionlog;
$logAction->item_type = static::class;
$logAction->item_id = $this->id;
$logAction->target_type = static::class;
$logAction->target_id = $this->id;
$logAction->created_at = date('Y-m-d H:i:s');
$logAction->created_by = auth()->id();
$logAction->log_meta = json_encode($companyChange);
$logAction->logaction('update');
}
/**
* Establishes the user -> department relationship
*
@@ -651,6 +750,27 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
return $this->last_name ? $this->first_name.' '.$this->last_name : $this->first_name;
}
protected function linkLightColor(): Attribute
{
return Attribute::make(
get: fn (?string $value) => CssColor::sanitize($value, '#296282'),
);
}
protected function linkDarkColor(): Attribute
{
return Attribute::make(
get: fn (?string $value) => CssColor::sanitize($value, '#5fa4cc'),
);
}
protected function navLinkColor(): Attribute
{
return Attribute::make(
get: fn (?string $value) => CssColor::sanitize($value, '#ffffff'),
);
}
/**
* Establishes the user -> assets relationship
*
@@ -725,9 +845,10 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
{
return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at');
}
public function directLicenses()
{
return $this->belongsToMany(\App\Models\License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed();
return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed();
}
/**
@@ -1338,7 +1459,14 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
*/
public function scopeOrderCompany($query, $order)
{
return $query->leftJoin('companies as companies_user', 'users.company_id', '=', 'companies_user.id')->orderBy('companies_user.name', $order);
$sub = DB::table('company_user')
->join('companies', 'companies.id', '=', 'company_user.company_id')
->select('company_user.user_id', DB::raw('MIN(companies.name) as min_company_name'))
->groupBy('company_user.user_id');
return $query
->leftJoinSub($sub, 'companies_sort', 'companies_sort.user_id', '=', 'users.id')
->orderBy('companies_sort.min_company_name', $order);
}
/**
@@ -1393,28 +1521,39 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
->orwhereRaw('CONCAT(users.first_name," ",users.last_name) LIKE \''.$search.'%\'');
}
public function scopeWithInventoryRelations($query, int $id)
public function scopeWithInventoryRelations($query, int $id, bool $withLicenses = true, bool $withAccessories = true, bool $withConsumables = true)
{
return $query->where('id', $id)
->with([
'assets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.assignedAssets.assignedTo',
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.components.category',
$with = [
'assets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.assignedAssets.assignedTo',
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.components.category',
];
if ($withLicenses) {
$with = array_merge($with, [
'assets.licenses',
'assets.licenses.category',
'directLicenses.category',
'licenses.category',
]);
}
if ($withAccessories) {
$with = array_merge($with, [
'assets.assignedAccessories',
'assets.assignedAccessories.accessory.category',
'accessories.log' => fn ($query) => $query->withTrashed()
@@ -1423,17 +1562,23 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
->where('action_type', 'accepted'),
'accessories.category',
'accessories.manufacturer',
]);
}
if ($withConsumables) {
$with = array_merge($with, [
'consumables.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'consumables.category',
'consumables.manufacturer',
'directLicenses.category',
'licenses.category',
])
->withTrashed();
]);
}
return $query->where('id', $id)->with($with)->withTrashed();
}
/**
* Get all direct and indirect subordinates for this user.
*
@@ -139,23 +139,22 @@ class CheckinAssetNotification extends Notification
$target = $this->target;
$item = $this->item;
$note = $this->note;
//
return GoogleChatMessage::create()
->to($this->settings->webhook_endpoint)
->card(
Card::create()
->header(
'<strong>'.trans('mail.Asset_Checkin_Notification', ['tag' => '']).'</strong>' ?: '',
'<strong>' . trans('mail.Asset_Checkin_Notification', ['tag' => '']) . '</strong>' ?: '',
htmlspecialchars_decode($item->display_name) ?: '',
)
->section(
Section::create(
KeyValue::create(
trans('mail.checked_into') ?: '',
($item->location) ? $item->location->name : '',
trans('admin/hardware/form.status').': '.$item->status?->name,
)
->onClick(route('hardware.show', $item->id))
($item->location) ? $item->location?->name : '',
trans('admin/hardware/form.status') . ': ' . $item->status?->name
)->onClick(route('hardware.show', $item->id))
)
)
);
+21 -6
View File
@@ -14,12 +14,27 @@ class AccessoryObserver
*/
public function updated(Accessory $accessory)
{
$logAction = new Actionlog;
$logAction->item_type = Accessory::class;
$logAction->item_id = $accessory->id;
$logAction->created_at = date('Y-m-d H:i:s');
$logAction->created_by = auth()->id();
$logAction->logaction('update');
$changed = [];
foreach ($accessory->getRawOriginal() as $key => $value) {
if ($key === 'updated_at') {
continue;
}
if ($accessory->getRawOriginal()[$key] != $accessory->getAttributes()[$key]) {
$changed[$key]['old'] = $accessory->getRawOriginal()[$key];
$changed[$key]['new'] = $accessory->getAttributes()[$key];
}
}
if (count($changed) > 0) {
$logAction = new Actionlog;
$logAction->item_type = Accessory::class;
$logAction->item_id = $accessory->id;
$logAction->created_at = date('Y-m-d H:i:s');
$logAction->created_by = auth()->id();
$logAction->log_meta = json_encode($changed);
$logAction->logaction('update');
}
}
/**
+2
View File
@@ -15,6 +15,8 @@ class ComponentObserver
public function updated(Component $component)
{
$changed = [];
foreach ($component->getRawOriginal() as $key => $value) {
// Check and see if the value changed
if ($component->getRawOriginal()[$key] != $component->getAttributes()[$key]) {
+21 -6
View File
@@ -14,12 +14,27 @@ class LicenseObserver
*/
public function updated(License $license)
{
$logAction = new Actionlog;
$logAction->item_type = License::class;
$logAction->item_id = $license->id;
$logAction->created_at = date('Y-m-d H:i:s');
$logAction->created_by = auth()->id();
$logAction->logaction('update');
$changed = [];
foreach ($license->getRawOriginal() as $key => $value) {
if ($key === 'updated_at') {
continue;
}
if ($license->getRawOriginal()[$key] != $license->getAttributes()[$key]) {
$changed[$key]['old'] = $license->getRawOriginal()[$key];
$changed[$key]['new'] = $license->getAttributes()[$key];
}
}
if (count($changed) > 0) {
$logAction = new Actionlog;
$logAction->item_type = License::class;
$logAction->item_id = $license->id;
$logAction->created_at = date('Y-m-d H:i:s');
$logAction->created_by = auth()->id();
$logAction->log_meta = json_encode($changed);
$logAction->logaction('update');
}
}
/**
+47 -11
View File
@@ -16,6 +16,8 @@ class UserObserver
{
// ONLY allow these fields to be stored
// NOTE: company_id is intentionally excluded — company membership changes are logged
// via User::syncCompaniesWithLogging() against the pivot table instead.
$allowed_fields = [
'email',
'activated',
@@ -31,7 +33,6 @@ class UserObserver
'employee_num',
'username',
'notes',
'company_id',
'ldap_import',
'locale',
'two_factor_enrolled',
@@ -58,18 +59,49 @@ class UserObserver
// Make sure the info is in the allow fields array
if (in_array($key, $allowed_fields)) {
// Check and see if the value changed
if ($user->getRawOriginal()[$key] != $user->getAttributes()[$key]) {
$oldValue = $user->getRawOriginal()[$key];
$newValue = $user->getAttributes()[$key];
$changed[$key]['old'] = $user->getRawOriginal()[$key];
$changed[$key]['new'] = $user->getAttributes()[$key];
// Do not store the hashed password in changes
if ($key == 'password') {
$changed['password']['old'] = '*************';
$changed['password']['new'] = '*************';
if ($key === 'permissions') {
// Compare decoded to avoid spurious diffs from key reordering or type coercion.
$oldDecoded = json_decode($oldValue ?? '{}', true) ?: [];
$newDecoded = json_decode($newValue ?? '{}', true) ?: [];
if ($oldDecoded == $newDecoded) {
continue;
}
// Only log the permission keys that actually changed.
$diffOld = [];
$diffNew = [];
foreach (array_unique(array_merge(array_keys($oldDecoded), array_keys($newDecoded))) as $permKey) {
$oldPerm = $oldDecoded[$permKey] ?? null;
$newPerm = $newDecoded[$permKey] ?? null;
// null and "0" are both "inherit" — treat them as equivalent
$normalizedOld = ($oldPerm === null || $oldPerm === '0' || $oldPerm === 0) ? null : $oldPerm;
$normalizedNew = ($newPerm === null || $newPerm === '0' || $newPerm === 0) ? null : $newPerm;
if ($normalizedOld !== $normalizedNew) {
$diffOld[$permKey] = $oldPerm;
$diffNew[$permKey] = $newPerm;
}
}
if (! empty($diffOld) || ! empty($diffNew)) {
$changed['permissions']['old'] = json_encode($diffOld);
$changed['permissions']['new'] = json_encode($diffNew);
}
continue;
}
if ($oldValue == $newValue) {
continue;
}
$changed[$key]['old'] = $oldValue;
$changed[$key]['new'] = $newValue;
// Do not store the hashed password in changes
if ($key == 'password') {
$changed['password']['old'] = '*************';
$changed['password']['new'] = '*************';
}
}
@@ -79,12 +111,16 @@ class UserObserver
$logAction = new Actionlog;
$logAction->item_type = User::class;
$logAction->item_id = $user->id;
$logAction->target_type = User::class; // can we instead say $logAction->item = $asset ?
$logAction->target_type = User::class;
$logAction->target_id = $user->id;
$logAction->created_at = date('Y-m-d H:i:s');
$logAction->created_by = auth()->id();
$logAction->log_meta = json_encode($changed);
$logAction->logaction('update');
// Let syncCompaniesWithLogging() merge company changes into this entry
// rather than creating a separate log row for the same edit session.
$user->currentUpdateLogId = $logAction->id;
}
}
+9
View File
@@ -218,6 +218,15 @@ class AccessoryPresenter extends Presenter
'visible' => true,
'formatter' => 'polymorphicItemFormatter',
],
[
'field' => 'assigned_to.companies',
'searchable' => true,
'sortable' => false,
'switchable' => true,
'title' => trans('general.companies'),
'visible' => true,
'formatter' => 'companiesArrayLinkFormatter',
],
[
'field' => 'note',
'searchable' => false,
+19 -3
View File
@@ -17,7 +17,7 @@ class AssetPresenter extends Presenter
*
* @return string
*/
public static function dataTableLayout()
public static function dataTableLayout($hide_fields = [])
{
$layout = [
[
@@ -278,7 +278,23 @@ class AssetPresenter extends Presenter
'title' => trans('general.updated_at'),
'visible' => false,
'formatter' => 'dateDisplayFormatter',
], [
],
];
if (! in_array('deleted_at', $hide_fields)) {
$layout[] = [
'field' => 'deleted_at',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.deleted_at'),
'visible' => true,
'formatter' => 'dateDisplayFormatter',
];
}
$layout = array_merge($layout, [
[
'field' => 'last_checkout',
'searchable' => false,
'sortable' => true,
@@ -323,7 +339,7 @@ class AssetPresenter extends Presenter
'formatter' => 'trueFalseFormatter',
],
];
]);
// This looks complicated, but we have to confirm that the custom fields exist in custom fieldsets
// *and* those fieldsets are associated with models, otherwise we'll trigger
+1 -1
View File
@@ -77,7 +77,7 @@ class CategoryPresenter extends Presenter
'searchable' => false,
'sortable' => true,
'class' => 'css-envelope',
'title' => 'Send Email',
'title' => trans('general.send_email'),
'visible' => true,
'formatter' => 'trueFalseFormatter',
], [
+3 -3
View File
@@ -280,13 +280,13 @@ class LicensePresenter extends Presenter
'formatter' => 'emailFormatter',
],
[
'field' => 'assigned_user.company',
'field' => 'assigned_user.companies',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('general.company'),
'title' => trans('general.companies'),
'visible' => true,
'formatter' => 'companiesLinkObjFormatter',
'formatter' => 'companiesArrayLinkFormatter',
],
[
'field' => 'assigned_user.department',
+4 -4
View File
@@ -83,13 +83,13 @@ class UserPresenter extends Presenter
'formatter' => 'usersLinkFormatter',
],
[
'field' => 'company',
'field' => 'companies',
'searchable' => true,
'sortable' => true,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/companies/table.title'),
'title' => trans('general.companies'),
'visible' => false,
'formatter' => 'companiesLinkObjFormatter',
'formatter' => 'companiesArrayLinkFormatter',
],
[
'field' => 'employee_num',
+4
View File
@@ -23,11 +23,13 @@ use App\Observers\LocationObserver;
use App\Observers\MaintenanceObserver;
use App\Observers\SettingObserver;
use App\Observers\UserObserver;
use App\View\Composers\SidebarComposer;
use Illuminate\Pagination\Paginator;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Rollbar\Laravel\RollbarServiceProvider;
@@ -75,6 +77,8 @@ class AppServiceProvider extends ServiceProvider
Paginator::useBootstrap();
View::composer('layouts.default', SidebarComposer::class);
Schema::defaultStringLength(191);
Accessory::observe(AccessoryObserver::class);
Asset::observe(AssetObserver::class);
@@ -3,6 +3,7 @@
namespace App\Providers;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Category;
@@ -22,6 +23,7 @@ use App\Models\PredefinedKit;
use App\Models\Statuslabel;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Tabuna\Breadcrumbs\Breadcrumbs;
use Tabuna\Breadcrumbs\Trail;
@@ -119,6 +121,24 @@ class BreadcrumbsServiceProvider extends ServiceProvider
->push(trans('general.update'))
);
Breadcrumbs::for('accessories.checkout.show', fn (Trail $trail, Accessory $accessory) => $trail->parent('accessories.show', $accessory)
->push(trans('general.checkout'))
);
Breadcrumbs::for('accessories.checkin.show', function (Trail $trail, int $accessoryID) {
$checkout = AccessoryCheckout::find($accessoryID);
$accessory = $checkout ? Accessory::find($checkout->accessory_id) : null;
$trail->parent('accessories.index');
if ($accessory) {
$trail->push($accessory->name, route('accessories.show', $accessory));
}
$trail->push(trans('general.checkin'));
});
Breadcrumbs::for('clone/accessories', fn (Trail $trail, Accessory $accessory) => $trail->parent('accessories.show', $accessory)
->push(trans('general.clone'))
);
/**
* Categories Breadcrumbs
*/
@@ -184,6 +204,25 @@ class BreadcrumbsServiceProvider extends ServiceProvider
->push(trans('general.clone'), route('components.create'))
);
Breadcrumbs::for('components.checkout.show', function (Trail $trail, int $componentID) {
$component = Component::find($componentID);
$trail->parent('components.index');
if ($component) {
$trail->push($component->name, route('components.show', $component));
}
$trail->push(trans('general.checkout'));
});
Breadcrumbs::for('components.checkin.show', function (Trail $trail, int $componentAssetId) {
$componentAsset = DB::table('components_assets')->find($componentAssetId);
$component = $componentAsset ? Component::find($componentAsset->component_id) : null;
$trail->parent('components.index');
if ($component) {
$trail->push($component->name, route('components.show', $component));
}
$trail->push(trans('general.checkin'));
});
/**
* Consumables Breadcrumbs
*/
@@ -204,6 +243,19 @@ class BreadcrumbsServiceProvider extends ServiceProvider
->push(trans('general.update'))
);
Breadcrumbs::for('consumables.checkout.show', function (Trail $trail, $consumablesID) {
$consumable = Consumable::find($consumablesID);
$trail->parent('consumables.index');
if ($consumable) {
$trail->push($consumable->name, route('consumables.show', $consumable));
}
$trail->push(trans('general.checkout'));
});
Breadcrumbs::for('consumables.clone.create', fn (Trail $trail, Consumable $consumable) => $trail->parent('consumables.show', $consumable)
->push(trans('general.clone'))
);
/**
* Custom fields Breadcrumbs
*/
+6
View File
@@ -103,5 +103,11 @@ class RouteServiceProvider extends ServiceProvider
return Limit::perMinute(config('auth.password_reset.max_attempts_per_min'))->by(optional($request->user())->id ?: $request->ip());
});
// Rate limiter for two-factor authentication — keyed on user ID since the user is already
// password-authenticated at this stage, preventing distributed brute force across IPs.
RateLimiter::for('two_factor', function (Request $request) {
return Limit::perMinute(config('auth.two_factor.max_attempts_per_min'))->by(optional($request->user())->id ?: $request->ip());
});
}
}
+1 -37
View File
@@ -30,43 +30,7 @@ class SettingsServiceProvider extends ServiceProvider
// Share common setting variables with all views.
view()->composer('*', function ($view) {
$view->with('snipeSettings', Setting::getSettings());
});
// Make sure the limit is actually set, is an integer and does not exceed system limits
app()->singleton('api_limit_value', function () {
$limit = config('app.max_results');
$int_limit = intval(request('limit'));
if ((abs($int_limit) > 0) && ($int_limit <= config('app.max_results'))) {
$limit = abs($int_limit);
}
return $limit;
});
// Make sure the offset is actually set and is an integer.
// If 'page' is passed without 'offset', derive the offset from the page number.
app()->singleton('api_offset_value', function () {
if (request()->filled('page') && ! request()->filled('offset')) {
$page = max(1, intval(request('page')));
return ($page - 1) * (int) app('api_limit_value');
}
return intval(request('offset'));
});
// Resolve the current page number for inclusion in API list responses.
// Supports both page= and legacy offset= parameters.
app()->singleton('api_current_page', function () {
if (request()->filled('page') && ! request()->filled('offset')) {
return max(1, intval(request('page')));
}
$limit = (int) app('api_limit_value');
$offset = (int) app('api_offset_value');
return $limit > 0 ? (int) floor($offset / $limit) + 1 : 1;
$view->with('settings', Setting::getSettings());
});
/**

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