Compare commits

...

272 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 c02a6c105a Bumped version 2026-05-26 23:24:04 +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 cfa8069953 Merge remote-tracking branch 'origin/develop' 2026-05-25 13:35:12 +01:00
snipe b3be2baf40 Merge remote-tracking branch 'origin/develop' 2026-05-23 01:54:06 +01: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 069912d051 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-05-22 13:06:41 +01:00
snipe 86245ad4ae Merge remote-tracking branch 'origin/develop' 2026-05-22 12:44:37 +01:00
snipe c8bafdad79 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-05-22 12:28:56 +01:00
snipe c94fce2367 Merge remote-tracking branch 'origin/develop' 2026-05-22 12:00:57 +01:00
snipe 653b1327cb Merge remote-tracking branch 'origin/develop' 2026-05-22 11:56:32 +01:00
snipe 849b217300 Merge remote-tracking branch 'origin/develop' 2026-05-22 10:44:13 +01:00
snipe 371f096e54 Merge remote-tracking branch 'origin/develop' 2026-05-22 09:31:44 +01:00
snipe 72a11113e7 Merge remote-tracking branch 'origin/develop' 2026-05-21 20:20:17 +01:00
snipe b0635f24db Merge remote-tracking branch 'origin/develop' 2026-05-21 16:12:50 +01:00
snipe 96088c416e Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-05-21 15:26:47 +01:00
snipe c8f3e833e5 Prod assets 2026-05-21 15:26:15 +01:00
snipe 5307a44fab Merge remote-tracking branch 'origin/develop' 2026-05-21 15:06:36 +01:00
snipe 2d6eb5d80a Merge remote-tracking branch 'origin/develop' 2026-05-20 18:08:38 +01:00
snipe 90e2c105cd Merge remote-tracking branch 'origin/develop' 2026-05-20 18:05:03 +01:00
Godfrey M 0c59ca70cf adds 3rd pluralization to unaccepted_profile_warning 2026-05-19 15:35:14 -07:00
snipe 875b0bbdec Merge remote-tracking branch 'origin/develop' 2026-05-19 08:38:14 +01:00
snipe be1f1bd1c5 Merge remote-tracking branch 'origin/develop' 2026-05-19 08:09:13 +01:00
snipe c9be696c84 Merge remote-tracking branch 'origin/develop' 2026-05-18 20:14:54 +01:00
snipe 187f160b21 Merge remote-tracking branch 'origin/develop' 2026-05-18 16:31:34 +01:00
snipe 8908b67b3d Merge remote-tracking branch 'origin/develop' 2026-05-18 16:26:27 +01:00
snipe 4373f761c7 Merge remote-tracking branch 'origin/develop' 2026-05-18 16:18:04 +01:00
snipe 8e9bd5dbb1 Merge remote-tracking branch 'origin/develop' 2026-05-18 13:54:48 +01:00
snipe 751541a54d Merge remote-tracking branch 'origin/develop' 2026-05-18 13:12:22 +01:00
snipe 3972799e56 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:47:05 +01:00
snipe db2afd0dc7 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:33:42 +01:00
snipe 460daf71b6 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:04:12 +01:00
snipe 3074bae47c Merge remote-tracking branch 'origin/develop' 2026-05-18 11:56:26 +01:00
snipe 0f80950a91 Merge remote-tracking branch 'origin/develop' 2026-05-15 01:56:34 +01:00
snipe 2620b60048 Merge remote-tracking branch 'origin/develop' 2026-05-15 00:43:51 +01:00
snipe 81b1cdc6e9 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:58:04 +01:00
snipe 0304933c53 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:46:42 +01:00
snipe f0d84f5350 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:34:42 +01:00
snipe 1ad562f8b9 Merge remote-tracking branch 'origin/develop' 2026-05-14 12:38:37 +01:00
snipe a5cea247f1 Merge remote-tracking branch 'origin/develop' 2026-05-14 11:35:54 +01:00
snipe 571bc39495 Merge remote-tracking branch 'origin/develop' 2026-05-14 10:40:52 +01:00
snipe 8ea78fae21 Merge remote-tracking branch 'origin/develop' 2026-05-13 21:59:26 +01:00
snipe ed6b3c04ab Merge remote-tracking branch 'origin/develop' 2026-05-13 12:18:25 +01:00
snipe a4ca0a592f Merge remote-tracking branch 'origin/develop' 2026-05-13 10:20:00 +01:00
snipe 90c8689596 Prod assets 2026-05-12 19:43:34 +01:00
1414 changed files with 17194 additions and 56438 deletions
+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
+2 -1
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
+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';
@@ -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);
+25 -13
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,6 +609,11 @@ class AssetsController extends Controller
])->with('model', 'status', 'assignedTo')
->NotArchived();
// 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)) {
@@ -610,6 +621,10 @@ class AssetsController extends Controller
}
}
if ($request->filled('excludeId')) {
$assets->where('assets.id', '!=', (int) $request->input('excludeId'));
}
if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
$assets = $assets->RTD();
}
@@ -898,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')));
}
@@ -1056,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'));
@@ -1120,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');
}
@@ -1263,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').'%');
}
@@ -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,15 +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);
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$locations->where('locations.company_id', $request->input('companyId'));
}
$page = 1;
if ($request->filled('page')) {
$page = $request->input('page');
@@ -440,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';
+20 -7
View File
@@ -396,13 +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->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $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'))
@@ -617,12 +626,16 @@ class UsersController extends Controller
$user->groups()->sync($request->input('groups'));
}
// Sync company memberships when company_ids[] or company_id is provided
if ($request->has('company_ids') || $request->filled('company_id')) {
$companyIds = array_filter(
(array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))
);
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser(array_map('intval', $companyIds)));
// 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);
}
@@ -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,6 +10,7 @@ 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;
@@ -95,6 +96,28 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
}
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;
@@ -240,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
@@ -255,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) {
+32 -10
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()) {
@@ -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);
@@ -175,7 +175,15 @@ class BulkUsersController extends Controller
->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)) {
@@ -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');
}
@@ -245,7 +293,15 @@ class BulkUsersController extends Controller
User::whereIn('id', $user_raw_array)->where('id', '!=', auth()->id())
->update(['company_id' => $scalarCompanyId]);
foreach ($users as $user) {
$user->companies()->sync($allowedIds);
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);
}
}
}
@@ -260,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);
}
@@ -334,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);
@@ -440,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) {
+13 -2
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;
@@ -702,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);
@@ -222,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
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;
@@ -75,7 +74,6 @@ class Kernel extends HttpKernel
CheckUserIsActivated::class,
CheckForTwoFactor::class,
CreateFreshApiToken::class,
AssetCountForSidebar::class,
CheckColorSettings::class,
AuthenticateSession::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);
}
}
+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)],
];
}
}
+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),
@@ -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),
@@ -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,
+1 -1
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,
+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();
}
}
}
+1 -1
View File
@@ -75,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'));
+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',
+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);
}
/**
+79 -5
View File
@@ -15,6 +15,7 @@ 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;
/**
@@ -155,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;
}
}
}
@@ -269,7 +286,7 @@ final class Company extends SnipeModel
{
return ! self::isFullMultipleCompanySupportEnabled()
|| auth()->user()->isSuperUser()
|| empty(self::getCurrentUserCompanyIds());
|| ! empty(self::getCurrentUserCompanyIds());
}
/**
@@ -381,10 +398,17 @@ final class Company extends SnipeModel
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) {
@@ -392,6 +416,15 @@ final class Company extends SnipeModel
});
}
// 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);
});
@@ -402,13 +435,54 @@ final class Company extends SnipeModel
$table = ($table_name) ? $table_name.'.' : $query->getModel()->getTable().'.';
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));
}
/**
* I legit do not know what this method does, but we can't remove it (yet).
*
+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
+1 -1
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',
+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;
+98 -25
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;
@@ -322,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(),
);
}
@@ -600,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 == '');
@@ -625,6 +625,43 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
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.
*
@@ -713,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
*
@@ -1464,28 +1522,38 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
}
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()
@@ -1494,16 +1562,21 @@ 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();
}
/**
@@ -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');
}
}
/**
+8 -3
View File
@@ -75,13 +75,18 @@ class UserObserver
foreach (array_unique(array_merge(array_keys($oldDecoded), array_keys($newDecoded))) as $permKey) {
$oldPerm = $oldDecoded[$permKey] ?? null;
$newPerm = $newDecoded[$permKey] ?? null;
if ($oldPerm != $newPerm) {
// 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;
}
}
$changed['permissions']['old'] = json_encode($diffOld);
$changed['permissions']['new'] = json_encode($diffNew);
if (! empty($diffOld) || ! empty($diffNew)) {
$changed['permissions']['old'] = json_encode($diffOld);
$changed['permissions']['new'] = json_encode($diffNew);
}
continue;
}
+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',
], [
+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
*/
@@ -30,6 +30,7 @@ class SettingsServiceProvider extends ServiceProvider
// Share common setting variables with all views.
view()->composer('*', function ($view) {
$view->with('snipeSettings', Setting::getSettings());
$view->with('settings', Setting::getSettings());
});
/**
@@ -349,6 +349,20 @@ class ValidationServiceProvider extends ServiceProvider
return in_array($value, $options);
});
Validator::replacer('fmcs_location', function ($message, $attribute, $rule, $parameters, $validator) {
$locationId = $validator->getData()[$attribute] ?? null;
$location = $locationId ? Location::find($locationId) : null;
return str_replace(
[':location', ':location_company'],
[
$location?->name ?? '?',
$location?->company?->name ?? trans('general.unassigned'),
],
$message
);
});
// Validates that the company of the validated object matches the company of the location in case of scoped locations
Validator::extend('fmcs_location', function ($attribute, $value, $parameters, $validator) {
$settings = Setting::getSettings();
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class CssColor implements ValidationRule
{
private static function pattern(): string
{
$num = '\s*[\d.]+\s*';
$pct = '\s*[\d.]+%\s*';
$alpha = '(?:,\s*[\d.]+\s*)?';
$hex = '#[0-9a-fA-F]{3,8}';
$rgb = "rgba?\({$num},{$num},{$num}{$alpha}\)";
$hsl = "hsla?\({$num},{$pct},{$pct}{$alpha}\)";
return "/^(?:{$hex}|{$rgb}|{$hsl})$/i";
}
/**
* Return $value if it is a safe CSS color, otherwise return $default.
* Use this for defense-in-depth when rendering color values already in the database.
*/
public static function sanitize(?string $value, string $default): string
{
if ($value && preg_match(self::pattern(), trim($value))) {
return $value;
}
return $default;
}
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! preg_match(self::pattern(), $value)) {
$fail(trans('validation.valid_css_color'));
}
}
}
+12 -9
View File
@@ -7,11 +7,11 @@ use App\Models\PredefinedKit;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* Class incapsulates checkout logic for reuse in different controllers
*
* @author [D. Minaev.] [<dmitriy.minaev.v@gmail.com>]
*/
class PredefinedKitCheckoutService
@@ -19,9 +19,9 @@ class PredefinedKitCheckoutService
use AuthorizesRequests;
/**
* @param Request $request, this function works with fields: checkout_at, expected_checkin, note
* @param PredefinedKit $kit kit for checkout
* @param User $user checkout target
* @param Request $request, this function works with fields: checkout_at, expected_checkin, note
* @param PredefinedKit $kit kit for checkout
* @param User $user checkout target
* @return array Empty array if all ok, else [string_error1, string_error2...]
*/
public function checkout(Request $request, PredefinedKit $kit, User $user)
@@ -93,7 +93,7 @@ class PredefinedKitCheckoutService
}
}
if ($quantity > 0) {
$errors[] = trans('admin/kits/general.none_models', ['model'=> $model->name, 'qty' => $model->pivot->quantity]);
$errors[] = trans('admin/kits/general.none_models', ['model' => $model->name, 'qty' => $model->pivot->quantity]);
}
}
@@ -107,9 +107,10 @@ class PredefinedKitCheckoutService
->with('freeSeats')
->get();
foreach ($licenses as $license) {
$this->authorize('checkout', $license);
$quantity = $license->pivot->quantity;
if ($quantity > count($license->freeSeats)) {
$errors[] = trans('admin/kits/general.none_licenses', ['license'=> $license->name, 'qty' => $license->pivot->quantity]);
$errors[] = trans('admin/kits/general.none_licenses', ['license' => $license->name, 'qty' => $license->pivot->quantity]);
}
for ($i = 0; $i < $quantity; $i++) {
$seats_to_add[] = $license->freeSeats[$i];
@@ -123,8 +124,9 @@ class PredefinedKitCheckoutService
{
$consumables = $kit->consumables()->with('users')->get();
foreach ($consumables as $consumable) {
$this->authorize('checkout', $consumable);
if ($consumable->numRemaining() < $consumable->pivot->quantity) {
$errors[] = trans('admin/kits/general.none_consumables', ['consumable'=> $consumable->name, 'qty' => $consumable->pivot->quantity]);
$errors[] = trans('admin/kits/general.none_consumables', ['consumable' => $consumable->name, 'qty' => $consumable->pivot->quantity]);
}
}
@@ -135,8 +137,9 @@ class PredefinedKitCheckoutService
{
$accessories = $kit->accessories()->with('users')->get();
foreach ($accessories as $accessory) {
$this->authorize('checkout', $accessory);
if ($accessory->numRemaining() < $accessory->pivot->quantity) {
$errors[] = trans('admin/kits/general.none_accessory', ['accessory'=> $accessory->name, 'qty' => $accessory->pivot->quantity]);
$errors[] = trans('admin/kits/general.none_accessory', ['accessory' => $accessory->name, 'qty' => $accessory->pivot->quantity]);
}
}
@@ -175,7 +178,7 @@ class PredefinedKitCheckoutService
]);
event(new CheckoutableCheckedOut($consumable, $user, $admin, $note));
}
//accessories
// accessories
foreach ($accessories_to_add as $accessory) {
$accessory->assigned_to = $user->id;
$accessory->users()->attach($accessory->id, [
+54
View File
@@ -0,0 +1,54 @@
<?php
// A View Composer is a callback Laravel runs right before a specific view renders.
// It's registered in AppServiceProvider bound to 'layouts.default', so it only fires
// when a full page is rendered — not on modal AJAX responses, select2 searches, or
// API requests. This replaces the old AssetCountForSidebar middleware, which ran on
// every web request regardless of what was returned.
namespace App\View\Composers;
use App\Models\Asset;
use App\Models\Setting;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class SidebarComposer
{
public function compose(View $view): void
{
// Guard against the setup wizard, where DB tables may not exist yet
try {
$settings = Setting::getSettings();
} catch (\Exception $e) {
Log::debug($e);
return;
}
try {
$due_for_checkin = Asset::DueForCheckin($settings)->count();
$overdue_for_checkin = Asset::OverdueForCheckin()->count();
$due_for_audit = Asset::DueForAudit($settings)->count();
$overdue_for_audit = Asset::OverdueForAudit()->count();
$view->with([
'total_assets' => Asset::AssetsForShow()->count(),
'total_rtd_sidebar' => Asset::RTD()->count(),
'total_deployed_sidebar' => Asset::Deployed()->count(),
'total_archived_sidebar' => Asset::Archived()->count(),
'total_pending_sidebar' => Asset::Pending()->count(),
'total_undeployable_sidebar' => Asset::Undeployable()->count(),
'total_byod_sidebar' => Asset::where('byod', 1)->count(),
'total_due_for_audit' => $due_for_audit,
'total_overdue_for_audit' => $overdue_for_audit,
'total_due_for_checkin' => $due_for_checkin,
'total_overdue_for_checkin' => $overdue_for_checkin,
'total_due_and_overdue_for_checkin' => $due_for_checkin + $overdue_for_checkin,
'total_due_and_overdue_for_audit' => $due_for_audit + $overdue_for_audit,
]);
} catch (\Exception $e) {
Log::debug($e);
}
}
}
Generated
+2 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ed0655f6c3c75cda1939dfc27b492029",
"content-hash": "09f6cf88befc67d5f5ead4d38c37e857",
"packages": [
{
"name": "alek13/slack",
@@ -16835,6 +16835,7 @@
"platform": {
"php": "^8.2",
"ext-curl": "*",
"ext-exif": "*",
"ext-fileinfo": "*",
"ext-iconv": "*",
"ext-json": "*",
+5 -1
View File
@@ -11,12 +11,16 @@
// This is used by the mysql dump options in spatie backup
$dump_options = [
'dump_binary_path' => env('DB_DUMP_PATH', '/usr/local/bin'), // only the path, so without 'mysqldump'
'use_single_transaction' => false,
'timeout' => 60 * 5, // 5 minute timeout
// 'exclude_tables' => ['table1', 'table2'],
// 'add_extra_option' => '--optionname=optionvalue',
];
// Default to false (preserves original behavior for non-RDS installs).
// Set DB_DUMP_SINGLE_TRANSACTION=true in .env for RDS or other environments
// that require --single-transaction (e.g. no LOCK TABLES privilege).
$dump_options['use_single_transaction'] = (env('DB_DUMP_SINGLE_TRANSACTION', 'false') === 'true');
// For modern versions of mysqldump, use --ssl-mode=DISABLED
if (env('DB_DUMP_SKIP_SSL') == 'true') {
// Correctly add the option as a string to the 'add_extra_option' key.
+1
View File
@@ -131,6 +131,7 @@ $config['allowed_upload_extensions_array'] = [
'docx',
'gif',
'ico',
'jfif',
'jpeg',
'jpg',
'json',
+6 -6
View File
@@ -1,11 +1,11 @@
<?php
return [
'app_version' => 'v8.5.1-pre',
'full_app_version' => 'v8.5.1-pre - build 22809-g86245ad4ae',
'build_version' => '22809',
'app_version' => 'v8.6.2',
'full_app_version' => 'v8.6.2 - build 23218-gc98eea1ce5',
'build_version' => '23218',
'prerelease_version' => '',
'hash_version' => 'g86245ad4ae',
'full_hash' => 'v8.5.1-pre-185-g86245ad4ae',
'branch' => 'develop',
'hash_version' => 'gc98eea1ce5',
'full_hash' => 'v8.6.2-212-gc98eea1ce5',
'branch' => 'master',
];
+11
View File
@@ -154,4 +154,15 @@ class CustomFieldFactory extends Factory
];
});
}
public function testMarkdownTextarea()
{
return $this->state(function () {
return [
'name' => 'Notes',
'help_text' => 'Additional notes about this asset. Markdown is supported.',
'element' => 'markdown-textarea',
];
});
}
}
+25
View File
@@ -430,6 +430,11 @@ class UserFactory extends Factory
return $this->appendPermission(['kits.delete' => '1']);
}
public function editPredefinedKits()
{
return $this->appendPermission(['kits.edit' => '1']);
}
public function viewPredefinedKits()
{
return $this->appendPermission(['kits.view' => '1']);
@@ -450,6 +455,26 @@ class UserFactory extends Factory
return $this->appendPermission(['assets.audit' => '1']);
}
public function manageModelFiles()
{
return $this->appendPermission(['models.files' => '1']);
}
public function manageLocationFiles()
{
return $this->appendPermission(['locations.files' => '1']);
}
public function manageCompanyFiles()
{
return $this->appendPermission(['companies.files' => '1']);
}
public function manageSupplierFiles()
{
return $this->appendPermission(['suppliers.files' => '1']);
}
private function appendPermission(array $permission)
{
return $this->state(function ($currentState) use ($permission) {
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->boolean('null_company_is_floater')->default(false)->after('scope_locations_fmcs');
});
}
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn('null_company_is_floater');
});
}
};
+15
View File
@@ -36,6 +36,7 @@ class CustomFieldSeeder extends Seeder
CustomField::factory()->count(1)->testEncrypted()->create();
CustomField::factory()->count(1)->testCheckbox()->create();
CustomField::factory()->count(1)->testRadio()->create();
CustomField::factory()->count(1)->testMarkdownTextarea()->create();
DB::table('custom_field_custom_fieldset')->insert([
[
@@ -109,6 +110,20 @@ class CustomFieldSeeder extends Seeder
'required' => 0,
],
[
'custom_field_id' => '9',
'custom_fieldset_id' => '1',
'order' => 0,
'required' => 0,
],
[
'custom_field_id' => '9',
'custom_fieldset_id' => '2',
'order' => 0,
'required' => 0,
],
]);
}
}
+2
View File
@@ -19,6 +19,7 @@ class DatabaseSeeder extends Seeder
public function run()
{
Model::unguard();
DB::statement('SET FOREIGN_KEY_CHECKS=0');
// Only create default settings if they do not exist in the db.
if (! Setting::first()) {
@@ -51,6 +52,7 @@ class DatabaseSeeder extends Seeder
Log::info($output);
Model::reguard();
DB::statement('SET FOREIGN_KEY_CHECKS=1');
DB::table('imports')->truncate();
DB::table('requested_assets')->truncate();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long

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