Compare commits

..

1383 Commits

Author SHA1 Message Date
snipe da86e919d9 Bumped version to v8.6.2 2026-06-13 19:19:50 +01:00
snipe 45d6a491cb Localization: Fixed #19173 - use translatoon string for depreciation helper 2026-06-13 18:02:00 +01:00
snipe 3dc90f89f6 Localization: Fixed #19176 - use translation key for “send email” 2026-06-13 17:57:09 +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 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 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 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 d12ad3d538 Table row selection: Use document.getElementById and DOM/jQuery element constructors 2026-06-12 19:55:07 +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 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 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 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 e430e4e6e2 Tests: Added explicit sort on tests to fix flakiness 2026-06-09 13:02:05 +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 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 0d870d540d Kits: Fixed FD-55737 - Kit License Association Lacks Object-Level Authorization 2026-06-08 21:55: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 cbc6dc94a5 Licenses/Accessory/Consumables: Fixed FD-55732 - confirm FMCS on backend 2026-06-08 17:08:18 +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 d87cd7cbb9 Users Merge: Fixed FD-55767 - added canEditAuthFields for users in merge 2026-06-08 16:57:05 +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 df0ee6020a Fixed FD-55803 - escape links 2026-06-08 16:36:17 +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 8f98c8a862 Accessory checkouts: Fixed #19154 - get checkout company by way of parent accessory 2026-06-08 14:38:49 +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 5656e4f5b7 Fixed #19136 - translate strings on importer 2026-06-04 18:22:37 +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 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 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 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 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 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 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 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 01b1c3923d Fixed #19119 - updated structure for accessort export, added tests 2026-06-01 18:25:43 +01:00
snipe 780fb76af8 Added jfif to extension list in config 2026-06-01 17:29:02 +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 edcb429366 Fixed test 2026-05-30 10:18:46 +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 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 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 c25d56ea85 Refactored licenses controller to use a pessimistic lock inside a transaction 2026-05-26 14:24:02 +01:00
snipe f92a9a6cc6 Made isFullMultipleCompanySupportEnabled a public method 2026-05-26 14:23:32 +01:00
snipe 988729fbeb Skip user records if user exists in another company if FMCS is enabled 2026-05-26 13:36:02 +01:00
snipe e00f7b5b67 Added tests 2026-05-26 13:31:33 +01:00
snipe 39fbe98313 Fixed overwriting ownership of import 2026-05-26 13:11:30 +01:00
snipe 46d5234fd7 Throttle TOTP requests 2026-05-26 13:04:26 +01:00
snipe dd4117bd5b Tighter guard on user imports auth fields if the user is authenticated (aka not run via cli) 2026-05-26 12:56:10 +01:00
snipe 4dcd5190df Merge pull request #19025 from grokability/move-api-singletons-into-middleware
Move API singletons from SettingServiceProvider into middleware
2026-05-26 12:07:03 +01:00
snipe 48728e83b2 Merge pull request #19051 from grokability/_multi-company-support
Allow user to be a member of multiple companies
2026-05-26 12:03:24 +01:00
snipe 087b895bba Merge branch 'develop' into _multi-company-support
# Conflicts:
#	app/Http/Controllers/Users/BulkUsersController.php
#	app/Presenters/LicensePresenter.php
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-05-26 11:53:48 +01:00
snipe 2ed28f7f7a Dev manifest 2026-05-26 11:48:34 +01:00
snipe 9f50328da2 Merge hell :( 2026-05-26 11:48:15 +01:00
snipe 3737b34913 Back-patch security fixes 2026-05-26 11:36:29 +01:00
snipe aa0eb24e80 Fixed merge mess 2026-05-26 11:15:30 +01:00
snipe 9d012dd06d WTF 2026-05-26 11:14:27 +01:00
snipe df28c80553 Dev assets 2026-05-26 11:03:55 +01:00
snipe 2a3a3f7818 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 11:03:55 +01:00
snipe 15cb7993f6 Moved password visibility toggle to snipeit.js 2026-05-26 11:03:55 +01:00
snipe 15529a0c9c Bulk checkin license seats 2026-05-26 11:03:55 +01:00
snipe d2c30dd08c Dev assets 2026-05-26 11:03:55 +01:00
snipe 972b27140a Updated assets 2026-05-26 11:03:55 +01:00
snipe cac13dd949 Dev assets 2026-05-26 11:03:55 +01:00
snipe 112bf498e6 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 11:03:55 +01:00
snipe 02488a62c1 Updated controllers 2026-05-26 11:03:55 +01:00
snipe f5313f6ec0 Updated dev assets 2026-05-26 11:03:55 +01:00
snipe 3206549170 Moved password visibility toggle to snipeit.js 2026-05-26 11:03:48 +01:00
snipe 59b621500f Bulk checkin license seats 2026-05-26 11:03:40 +01:00
snipe cd5716d66d Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-26 11:03:07 +01:00
snipe 6a68a38d71 Dev assets 2026-05-26 11:02:44 +01:00
snipe f23ea5ce8f Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 11:02:35 +01:00
snipe c893b69b5f Fixed merge conflict 2026-05-26 10:52:04 +01:00
snipe 269e6c4ef6 Dev assets *again* 2026-05-26 10:49:32 +01:00
snipe a0ab9d3a80 Updated dev assets 2026-05-26 10:49:06 +01:00
snipe cdd72cf372 Dev assets 2026-05-26 10:49:05 +01:00
snipe e38b8cdd68 Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 10:49:05 +01:00
snipe c44cb23dea Updated JS to add the array endpoint for company_ids (plural) 2026-05-26 10:49:05 +01:00
snipe 84bdfa98d1 Updated dev assets 2026-05-26 10:49:05 +01:00
snipe f3055e7442 Moved password visibility toggle to snipeit.js 2026-05-26 10:48:54 +01:00
snipe 9c36ade1e2 Bulk checkin license seats 2026-05-26 10:48:44 +01:00
snipe 4127c6a0c0 Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-26 10:48:24 +01:00
snipe c133c869ae Dev assets 2026-05-26 10:47:48 +01:00
snipe d74197aacc Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-26 10:47:40 +01:00
snipe c870dd0dae Updated assets 2026-05-26 10:41:48 +01:00
snipe 6d1d89105d Updated JS to add the array endpoint for company_ids (plural) 2026-05-26 10:41:30 +01:00
snipe f3a4f5edaa Allow query string or parameter for byserial 2026-05-26 10:41:23 +01:00
snipe 8f61d1e729 Add @CybotTM as a contributor 2026-05-26 10:41:23 +01:00
Sebastian Mendel 4782734ed4 Fix dead QUEUE_DRIVER env var name in templates and test config
`config/queue.php` reads `env('QUEUE_CONNECTION', 'sync')` since the
Laravel Shift in v6.0.0 (commit cc3c59bf97), but seven .env templates
and phpunit.xml still set `QUEUE_DRIVER` — the old Laravel <5.7 name
that the framework no longer reads. The default is `sync` anyway so
the gap is silent; but anyone copying these templates and trying to
enable an async driver (redis, database, beanstalkd, sqs) finds their
setting silently ignored.

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
2026-05-25 21:26:17 +02:00
snipe d099cbd8e5 Merge pull request #19067 from chrisnox/patch-1
Update README.md
2026-05-25 15:03:24 +01:00
snipe cfa8069953 Merge remote-tracking branch 'origin/develop' 2026-05-25 13:35:12 +01:00
snipe 45df8ea55e Include table prefixes on OAuth Clients 2026-05-25 13:34:57 +01:00
chrisnox 33846b0d61 Update README.md 2026-05-25 01:35:25 +02:00
chrisnox b7df1dcefb Update README.md 2026-05-25 01:35:25 +02:00
snipe 3b0278bd3a Nullsafe on requesting user 2026-05-23 02:11:36 +01:00
snipe b3be2baf40 Merge remote-tracking branch 'origin/develop' 2026-05-23 01:54:06 +01:00
snipe 3cff19f9ca Account for deleted adminuser in journal note for assets 2026-05-23 01:51:13 +01:00
snipe 4380a46d1c Fixed flaky test 2026-05-23 01:42:49 +01:00
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 6a1c3e29d0 Updated dev assets 2026-05-22 13:05:41 +01:00
snipe 9fc37cf6b9 Fixed parenthases 2026-05-22 13:03:20 +01:00
snipe b37adb8c49 Moved password visibility toggle to snipeit.js 2026-05-22 13:00:18 +01:00
snipe 86245ad4ae Merge remote-tracking branch 'origin/develop' 2026-05-22 12:44:37 +01:00
snipe e3afe3b74d Fixed #19042 - use markdown for demo settings 2026-05-22 12:44:26 +01:00
snipe c8bafdad79 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-05-22 12:28:56 +01:00
snipe ee3ebe32e2 Added newline 2026-05-22 12:28:14 +01:00
snipe 3060fd305b More pint compliance 2026-05-22 12:27:09 +01:00
snipe 72666cdd47 Bumped hash and improved the version console command 2026-05-22 12:25:36 +01:00
snipe 4fbd6b2f15 Don’t show the serial field if the license does not have one 2026-05-22 12:14:52 +01:00
snipe 10ee84cb26 Bumped hash and added pre-version 2026-05-22 12:05:57 +01:00
snipe c94fce2367 Merge remote-tracking branch 'origin/develop' 2026-05-22 12:00:57 +01:00
snipe 432e625186 Fixed #19057 - update last login on google auth 2026-05-22 12:00:47 +01:00
snipe 653b1327cb Merge remote-tracking branch 'origin/develop' 2026-05-22 11:56:32 +01:00
snipe d011ad3dde Merge pull request #19058 from grokability/bulk-seat-checkin-toolbar
🎥 Bulk checkin license seats
2026-05-22 11:56:19 +01:00
snipe 54d01409dc Added to assets license tab as well 2026-05-22 11:45:53 +01:00
snipe d5ce5a82de Bulk checkin license seats 2026-05-22 11:30:22 +01:00
snipe 849b217300 Merge remote-tracking branch 'origin/develop' 2026-05-22 10:44:13 +01:00
snipe b224cc636c Show number of selected, use checkboxEnabledFormatter on simple toolbars 2026-05-22 10:44:00 +01:00
snipe 371f096e54 Merge remote-tracking branch 'origin/develop' 2026-05-22 09:31:44 +01:00
snipe 5efb21eb0b Merge pull request #19056 from grokability/bulk-checkin-assets-from-ui
Bulk checkin assets from UI
2026-05-22 09:31:29 +01:00
snipe ec24da12a1 Use intended() for redirect back to where you were 2026-05-21 22:28:14 +01:00
snipe 6aa8d8e772 Added bulk checkin controller method 2026-05-21 22:04:47 +01:00
snipe 424ed48d06 Added strings (to do: combine these maybe?) 2026-05-21 22:04:33 +01:00
snipe 3c44ce8682 Added checkin option to bulk asset menu 2026-05-21 22:04:00 +01:00
snipe 948dadc333 Added blade 2026-05-21 22:03:36 +01:00
snipe 0ff2fb5cff Added route 2026-05-21 22:03:19 +01:00
snipe c0773772f4 Created test 2026-05-21 22:03:11 +01:00
snipe 3c1b18919a Graceful redirect if the user is not allowed 2026-05-21 21:12:35 +01:00
snipe 72a11113e7 Merge remote-tracking branch 'origin/develop' 2026-05-21 20:20:17 +01:00
snipe 978c8f81a5 Added test 2026-05-21 20:15:50 +01:00
snipe ac2162113d Fixed FD-55585 - check canceled_by_admin more closely 2026-05-21 20:10:26 +01:00
snipe b0635f24db Merge remote-tracking branch 'origin/develop' 2026-05-21 16:12:50 +01:00
snipe 34b4cf12e2 Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-21 16:12:19 +01:00
snipe fec0a1b2b5 Fixed #19052 - PUT next_audit_date does not produce audit log entry 2026-05-21 16:11:08 +01:00
snipe 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 4f943d4a7a Fixed FD-55580 - added selectlist gate and tests 2026-05-21 15:25:09 +01:00
snipe 37361ef52f Dev assets 2026-05-21 15:09:15 +01:00
snipe 5307a44fab Merge remote-tracking branch 'origin/develop' 2026-05-21 15:06:36 +01:00
snipe d97f579761 Fixed tests 2026-05-21 15:06:19 +01:00
snipe afc287b607 Fixed typo 2026-05-21 15:06:19 +01:00
snipe ded6515cbc Chekc auth before assigning S3 temporary link 2026-05-21 15:06:19 +01:00
snipe 1af9b42d82 Pint 2026-05-21 15:06:19 +01:00
snipe 403f9c848b Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-21 15:06:19 +01:00
snipe 480d252173 Fixed RB-4158 - handle numeric values better 2026-05-21 15:06:19 +01:00
snipe d329e5f862 Merge pull request #19053 from ubc-cpsc/fix/update-symfony-dev-dependencies
Remove direct symfony dom-crawler and css-selector dev dependencies
2026-05-21 13:55:26 +01:00
Joël Pittet d9bc110868 Remove direct symfony crawler dev dependencies 2026-05-20 18:59:45 -07:00
snipe e7c80b89eb Scope assets, locations, etc to the target, not the admin 2026-05-20 19:10:51 +01:00
snipe 50ba979840 Nicer formatting on user edit page when you cannot edit auth fields 2026-05-20 18:52:40 +01:00
snipe 6fd834e4d2 Tweaked light-label a little more 2026-05-20 18:48:17 +01:00
snipe 2d6eb5d80a Merge remote-tracking branch 'origin/develop' 2026-05-20 18:08:38 +01:00
snipe f74fedb226 Fixed RB-4138 - json validation on wonky params 2026-05-20 18:08:28 +01:00
snipe 90e2c105cd Merge remote-tracking branch 'origin/develop' 2026-05-20 18:05:03 +01:00
snipe 5976e93de2 Fixed RB-4136 - array to string conversion when people throw random crap at the API 2026-05-20 18:04:43 +01:00
snipe 34101c148f Fixed rollbar for labels 2026-05-20 18:02:34 +01:00
snipe 048a46b317 Fixed RB-4131 depreciation name error 2026-05-20 16:20:13 +01:00
snipe 6ae09e15fb Updated tests and transformers 2026-05-20 16:17:02 +01:00
snipe f03b27ec88 Updated validator to accept single company_id or array 2026-05-20 15:08:53 +01:00
snipe cc1e0d82dd Tweaked label CSS 2026-05-20 15:08:13 +01:00
snipe f233bd2d01 New link formatter for BS tables 2026-05-20 15:07:11 +01:00
snipe 7a8b22df26 Updated users select2 to use new data-dash 2026-05-20 15:07:00 +01:00
snipe 17df4a08a7 Updated JS to add the array endpoint for company_ids (plural) 2026-05-20 15:04:02 +01:00
snipe c377b41198 Updated controllers 2026-05-20 14:58:18 +01:00
Godfrey M 0c59ca70cf adds 3rd pluralization to unaccepted_profile_warning 2026-05-19 15:35:14 -07:00
snipe e9e9dfeeab Load companies to avoid n+1 2026-05-19 14:40:39 +01:00
snipe f8c084cde7 This is hacky - might need to revisit 2026-05-19 14:40:20 +01:00
snipe 8f7fa6c0f5 Use new label for view-assets (not sure if I like this yet) 2026-05-19 14:39:53 +01:00
snipe f381362130 Tweaked company select for multiple if FMCS is enabled 2026-05-19 14:39:27 +01:00
snipe bef4a50720 Use multi-select in bulk user edit 2026-05-19 14:39:04 +01:00
snipe 2a93de675f Handle pipe delimited companyes in user importer 2026-05-19 14:38:52 +01:00
snipe e5f41f8f17 Use more common companies string 2026-05-19 14:38:28 +01:00
snipe b9da8ee55c Use multi-select for create user modal 2026-05-19 14:37:26 +01:00
snipe bf525f7213 Tweaked label + style for table labels (say that 100 times fast) 2026-05-19 14:37:08 +01:00
snipe c9ef163142 Added trans_choice option for company/companies 2026-05-19 14:36:36 +01:00
snipe feb3bd58cf Fixed wrong reference in fallback 2026-05-19 14:31:17 +01:00
snipe f9288e450b Update seeder 2026-05-19 14:24:24 +01:00
snipe 541128dd7a Updated tranformers 2026-05-19 14:23:44 +01:00
snipe 23b9c881ad Updated presenter 2026-05-19 14:16:45 +01:00
snipe cacd6f7e9b Add pipe separator to import more than one company for a user 2026-05-19 13:26:38 +01:00
snipe 4db4314f18 Added getCurrentUserCompanyIds (plural) to Company model 2026-05-19 13:26:06 +01:00
snipe 51aa66a77d Changed formatting just a bit 2026-05-19 13:24:49 +01:00
snipe aa0b491080 Added tests 2026-05-19 13:04:16 +01:00
snipe c01c9201ee Updated to use multiple select on users edit/create 2026-05-19 13:04:05 +01:00
snipe 0ad1a5b6ba Changed size of divs 2026-05-19 13:02:57 +01:00
snipe 95909d552a Show the list of companies if the infoPanelObj has more than one 2026-05-19 11:22:24 +01:00
snipe a159c3b84e Scary scary migration
We don’t actually drop the company_id field here, but later code will stop using it on the users table. This migration creates and populates the pivot table
2026-05-19 11:16:36 +01:00
snipe 875b0bbdec Merge remote-tracking branch 'origin/develop' 2026-05-19 08:38:14 +01:00
snipe be5b74af90 Fixed RB-21535 - use withTrashed for maintenances on Activity Report 2026-05-19 08:38:03 +01:00
snipe be1f1bd1c5 Merge remote-tracking branch 'origin/develop' 2026-05-19 08:09:13 +01:00
snipe 5dcc8efcca Check for IDs in bulk actions 2026-05-19 08:08:59 +01:00
snipe c9be696c84 Merge remote-tracking branch 'origin/develop' 2026-05-18 20:14:54 +01:00
snipe 8748ddffd8 Merge pull request #19039 from grokability/add-rp-to-maintenances
🖼️ Add custom maintenance types, responsible party and assigned to to maintenances
2026-05-18 20:14:20 +01:00
snipe e19a9b23e5 Merge pull request #19043 from marcusmoore/fixes/18624-license-checkout-button-url
Fixed #18624: Remove trailing slashes in license urls
2026-05-18 20:13:34 +01:00
Marcus Moore 5752fe68f0 Remove trailing slash in urls 2026-05-18 12:03:50 -07:00
snipe 187f160b21 Merge remote-tracking branch 'origin/develop' 2026-05-18 16:31:34 +01:00
snipe a6bbf0edf0 Used lower PHP requirement in composer lock 2026-05-18 16:31:22 +01:00
snipe 8908b67b3d Merge remote-tracking branch 'origin/develop' 2026-05-18 16:26:27 +01:00
snipe 4a0797d59f Fixed RB-21533 - Undefined variable $indirectItemsCount on bulk print, added test 2026-05-18 16:26:15 +01:00
snipe 07f1f247de Merge pull request #19041 from grokability/dependabot/github_actions/develop/github/codeql-action-4
Bump github/codeql-action from 3 to 4
2026-05-18 16:18:55 +01:00
snipe 4373f761c7 Merge remote-tracking branch 'origin/develop' 2026-05-18 16:18:04 +01:00
snipe 559491d31a Updated composer 2026-05-18 16:17:52 +01:00
snipe 1f51155c92 Updated string for clarity (notes vs journal notes) 2026-05-18 16:15:23 +01:00
snipe 58f7370935 Updated test with correct hash 2026-05-18 16:13:36 +01:00
snipe 043292ff15 Finishing touches 2026-05-18 16:13:13 +01:00
snipe a04bf04900 Mark type as required 2026-05-18 16:11:16 +01:00
snipe 9408f4005c Updated migration 2026-05-18 16:06:37 +01:00
snipe f9567af55a Added/updated tests 2026-05-18 16:01:27 +01:00
snipe 62a0c3764e Pass URL param to the index 2026-05-18 15:45:59 +01:00
snipe 66cab56c47 Added notes 2026-05-18 15:45:42 +01:00
snipe dfa8590a65 Added type to breadcrumbs 2026-05-18 15:42:52 +01:00
dependabot[bot] ee10cc970f Bump github/codeql-action from 3 to 4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 14:36:24 +00:00
snipe 96a42d0f33 Added a few more scopes 2026-05-18 15:32:00 +01:00
snipe 57b257057a Moved scopes 2026-05-18 15:12:18 +01:00
snipe c45040818d Move the previous query scopes into the builder 2026-05-18 15:12:08 +01:00
snipe fe2d599099 Reorder bs table buttons 2026-05-18 15:00:21 +01:00
snipe 1d2ba0a8c1 Added builder for scopes 2026-05-18 14:59:11 +01:00
snipe 63454f8c63 Respect the view encrypted custom fields permission 2026-05-18 14:18:58 +01:00
snipe 8e9bd5dbb1 Merge remote-tracking branch 'origin/develop' 2026-05-18 13:54:48 +01:00
snipe 87d6328fb8 One more test tweak 2026-05-18 13:54:30 +01:00
snipe 5020aec71a Added action_type to tests 2026-05-18 13:51:04 +01:00
snipe 39ff553b3e Fixed encrypted custom fields escaping 2026-05-18 13:49:51 +01:00
snipe aea3877718 Updated gate 2026-05-18 13:39:33 +01:00
snipe dbaa900444 Fixed redirect on assets 2026-05-18 13:29:32 +01:00
snipe 751541a54d Merge remote-tracking branch 'origin/develop' 2026-05-18 13:12:22 +01:00
snipe 26382eb0a1 Better handle manufacturer in info panel 2026-05-18 13:12:13 +01:00
snipe 3972799e56 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:47:05 +01:00
snipe 50baed175f Removed elibyy/tcpdf-laravel - we call it directly now
- The wrapper package is no longer a dependency
- PDF generation (labels, checkout acceptance) continues to work via the raw TCPDF class
2026-05-18 12:46:26 +01:00
snipe d870b3625b This is hacky - need to figure out what’s the root problem 2026-05-18 12:42:53 +01:00
snipe ae2e51c66c Use CarbonInterval for token expiration 2026-05-18 12:41:52 +01:00
snipe db2afd0dc7 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:33:42 +01:00
snipe 826bbe37c9 Fixed #19035 - Added helptext and parsing back into manufacturer 2026-05-18 12:33:21 +01:00
snipe 27a637a7a4 Added assets-only user to demo 2026-05-18 12:18:49 +01:00
snipe 460daf71b6 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:04:12 +01:00
snipe 56d5f17dde Fixed #19036 - added table prefix to migration 2026-05-18 12:03:31 +01:00
snipe 3074bae47c Merge remote-tracking branch 'origin/develop' 2026-05-18 11:56:26 +01:00
snipe b9c7bcf035 Check for null in SCIM 2026-05-18 11:56:14 +01:00
snipe 071c46a91e Fixed copy button 2026-05-18 11:52:05 +01:00
snipe 49138f2cb1 Fixed link 2026-05-18 11:49:27 +01:00
snipe fd46794350 Added copy JS 2026-05-18 11:29:32 +01:00
snipe 9305c3e845 Moved strings 2026-05-18 11:28:47 +01:00
snipe 058da6bfef Added maintenance type strings 2026-05-18 11:09:48 +01:00
snipe a65ae59810 Changed phrasing 2026-05-18 11:09:26 +01:00
snipe 1967b3b7a7 Stubbed index and edit for maintenance types 2026-05-18 11:08:48 +01:00
snipe 30dbf1698b Added modal to create new maintenance type 2026-05-18 11:08:11 +01:00
snipe 8e1ad53a31 Switch to maintenance type table for maintenance type dropdown partial 2026-05-18 11:07:58 +01:00
snipe a5272968de Added hardware maintenance complete route 2026-05-18 11:07:14 +01:00
snipe 94e14e5ee9 Added tests 2026-05-18 11:06:54 +01:00
snipe db46e16530 Pint 2026-05-18 11:06:41 +01:00
snipe 0630ef9f89 Added routes 2026-05-18 11:06:21 +01:00
snipe 0f80950a91 Merge remote-tracking branch 'origin/develop' 2026-05-15 01:56:34 +01:00
snipe aae07bd3a7 Null guard for null created_at date 2026-05-15 01:56:22 +01:00
snipe 2620b60048 Merge remote-tracking branch 'origin/develop' 2026-05-15 00:43:51 +01:00
snipe 1cff2d67aa Added extra guard on user permission 2026-05-15 00:43:18 +01:00
snipe 80418d0b16 Fixed error formatting 2026-05-15 00:43:18 +01:00
snipe 14c5cff429 Added CheckinAndDeleteItems command 2026-05-15 00:43:18 +01:00
snipe 0ea6eb13c2 Merge pull request #19032 from grokability/optimize-incorrect-checkout-acceptances-command
Optimize incorrect checkout acceptances command
2026-05-14 23:58:19 +01:00
snipe 86cc20034f Added test 2026-05-14 23:11:03 +01:00
snipe 57f17e80a2 First pass 2026-05-14 23:03:59 +01:00
snipe 81b1cdc6e9 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:58:04 +01:00
snipe 12c3629c89 Added empty option to allow clear 2026-05-14 16:57:32 +01:00
snipe 0304933c53 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:46:42 +01:00
snipe 0a02c0b81a fixed test 2026-05-14 16:40:44 +01:00
snipe f0d84f5350 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:34:42 +01:00
snipe fdcc3f1968 Clearer messaging on acceptance 2026-05-14 16:03:24 +01:00
snipe 663bab1f9d Merge pull request #19030 from grokability/purchase-cost-fix
Fixed #19029 - switch back to text for purchase cost
2026-05-14 15:56:25 +01:00
snipe 8b1e312292 Normalize purchase_cost 2026-05-14 15:33:42 +01:00
snipe 9004211a59 Added text string 2026-05-14 15:04:48 +01:00
snipe 053eb91457 Update bulk asset controller 2026-05-14 15:03:55 +01:00
snipe 3810513224 Updated HTML fields back to text from number 2026-05-14 14:30:09 +01:00
snipe e50e0f0e34 Updated tests 2026-05-14 14:29:51 +01:00
snipe cdf73f9c89 Merge pull request #19028 from grokability/move-and-rename-trait
Move and rename CheckInOutRequest to CheckInOutTrait
2026-05-14 13:13:50 +01:00
snipe bc808cbe46 Pint 2026-05-14 12:50:31 +01:00
snipe fdc65fb1b2 Moved and renamed CheckInOutRequest 2026-05-14 12:50:23 +01:00
snipe 1ad562f8b9 Merge remote-tracking branch 'origin/develop' 2026-05-14 12:38:37 +01:00
snipe 3db9a15dd3 Merge pull request #19027 from grokability/add-fields-to-user-export
Fixed #18659 - added more data to user export
2026-05-14 12:32:24 +01:00
snipe 42bf43d68d Fixed #18659 - added more data to user export 2026-05-14 12:24:08 +01:00
snipe 4548ed8a45 Fixed flappy test 2026-05-14 11:45:20 +01:00
snipe a5cea247f1 Merge remote-tracking branch 'origin/develop' 2026-05-14 11:35:54 +01:00
snipe 407e2d0246 Merge pull request #19026 from grokability/scim-typo
Fixed SCIMConfig typo "string_starts_with"
2026-05-14 11:12:52 +01:00
snipe 2af7367480 Pint 2026-05-14 11:00:58 +01:00
snipe 29b9a78f54 Fixed typo “string_starts_with” 2026-05-14 11:00:25 +01:00
snipe 571bc39495 Merge remote-tracking branch 'origin/develop' 2026-05-14 10:40:52 +01:00
snipe 382a164b9d Updated composer.lock 2026-05-14 10:40:41 +01:00
snipe 19f70656ee Move API singletons from SettingServiceProvider into middleware 2026-05-13 22:20:46 +01:00
snipe 9216a7550f Merge pull request #19019 from grokability/adding-normal-pagination-to-api-responses
Added page number support in API, added `per_page`, `total_pages`, and `current_page` to API response
2026-05-13 22:16:40 +01:00
snipe 4f9ba7c6cc Updated to use fullUrlWithQuery 2026-05-13 22:08:27 +01:00
snipe afb7c69ac3 Merge pull request #19021 from marcusmoore/fixes/21081-missing-asset-in-maintenance
Fixed asset maintenances page not rendering with missing asset
2026-05-13 22:00:24 +01:00
snipe 8ea78fae21 Merge remote-tracking branch 'origin/develop' 2026-05-13 21:59:26 +01:00
snipe 5f232c0584 Merge pull request #19024 from grokability/api-disallow-own-priv-changes
Tighten permission changes and UI, fixed #18831
2026-05-13 21:59:15 +01:00
snipe ed931d497a Hide admin/superadmin sections if the user is not an admin/superadmin 2026-05-13 21:45:56 +01:00
snipe 59278c3f70 Missed a spot 2026-05-13 21:39:46 +01:00
snipe 179d031bb2 Fixed #18831 - better spacing for buttons 2026-05-13 21:32:36 +01:00
snipe dc1410aa70 Tweaked logic around messaging 2026-05-13 21:27:25 +01:00
snipe 0f595a8854 Updated preserve permissions action 2026-05-13 21:06:04 +01:00
snipe 70e1dcf1b4 Disallow self-editing of privs on user model 2026-05-13 20:06:51 +01:00
snipe 780e3e1cd9 Merge pull request #19023 from marcusmoore/fixes/users-export
Fixed duplicate headers in users csv export
2026-05-13 19:41:55 +01:00
Marcus Moore 339c93ebbf Add documentation for assertion 2026-05-13 11:27:26 -07:00
Marcus Moore b4bb1556be Add assertions 2026-05-13 11:26:04 -07:00
Marcus Moore ba96aa5a61 Write assertion implementation 2026-05-13 11:25:52 -07:00
snipe 2171556ec4 Merge pull request #19022 from marcusmoore/fixes/disable-debugbar-for-unaccepted-assets-report
Disabled debugbar on acceptance report
2026-05-13 19:18:53 +01:00
Marcus Moore 2d33368063 WIP: better assertions 2026-05-13 11:10:45 -07:00
Marcus Moore 93a2f74f9e Disable debugbar on acceptance report 2026-05-13 10:46:58 -07:00
Marcus Moore ee61084ac8 Ensure maintenance has asset before attempting to render 2026-05-13 10:11:50 -07:00
snipe 8d8a1889cd Drop the limit from the next/prev links unless they were specifically sent in the URL parameters 2026-05-13 17:17:14 +01:00
snipe f275cb6928 Pint 2026-05-13 17:10:41 +01:00
snipe db8de1f794 Codacy fixes 2026-05-13 17:10:34 +01:00
snipe d901e821cc Include next and previous urls in the payload 2026-05-13 16:55:55 +01:00
snipe 34a533b2d6 Added page number support in API, added per_page, total_pages, and current_page 2026-05-13 16:48:39 +01:00
snipe ed6b3c04ab Merge remote-tracking branch 'origin/develop' 2026-05-13 12:18:25 +01:00
snipe d3d37c70ab Merge pull request #19017 from grokability/FD-52058-importer-updated-at-timestamp
Fixed importer `created_at` timestamp getting weird on large imports
2026-05-13 12:18:01 +01:00
snipe 475e674fc6 Bumped laravel version in README 2026-05-13 12:17:44 +01:00
snipe 01436d0532 Import the model in test 2026-05-13 12:06:39 +01:00
snipe 96bf7d0c2b Fixed FD-52058 - Importer using wrong created_at date 2026-05-13 12:06:13 +01:00
snipe 529973aa77 Updated EULA PDF filename to include username 2026-05-13 10:32:17 +01:00
snipe a4ca0a592f Merge remote-tracking branch 'origin/develop' 2026-05-13 10:20:00 +01:00
snipe f4cd090ac6 Merge pull request #19012 from marcusmoore/fixes/activity-report-link
Fixed anchor tag on report index page
2026-05-13 02:41:35 +01:00
Marcus Moore 6d5e68274d Only write headers once 2026-05-12 16:36:26 -07:00
Marcus Moore 3e002cb940 Implement test and remove trailing comma in group column 2026-05-12 16:33:53 -07:00
Marcus Moore b7ea9a959c Scaffold some tests 2026-05-12 16:25:53 -07:00
Marcus Moore dc3a16c437 Update test macros 2026-05-12 16:25:00 -07:00
Marcus Moore 608af84253 Fix anchor tag 2026-05-12 15:13:29 -07:00
snipe 90c8689596 Prod assets 2026-05-12 19:43:34 +01:00
snipe 161d7e1c2b Dev assets 2026-05-12 19:42:20 +01:00
snipe 8627032c4f Bumped version to 8.5.0 2026-05-12 19:41:10 +01:00
snipe 5bead4fbcc Pint :( 2026-05-12 19:30:54 +01:00
snipe ef44ba5f97 Updated languages 2026-05-12 19:29:57 +01:00
snipe 2dc0ec9e7e Bumped php_max_major_minor for new 8.5 support 2026-05-12 19:27:50 +01:00
snipe afd435e895 Bumped php max requirement 2026-05-12 15:23:15 +01:00
snipe 80d1bf6a7a Better manufacturer check again :( 2026-05-12 15:12:38 +01:00
snipe 737f3ef3db Skip lookup URL if manufacturer was deleted 2026-05-12 15:07:55 +01:00
snipe d179f47274 Merge pull request #19007 from uberbrady/reintroduce_scim_logging
Change branch of home-forked SCIM server to re-introduce logging
2026-05-12 14:51:11 +01:00
snipe 1832d95371 Merge pull request #19009 from grokability/bulk-delete-licenses
🎥 Added bulk deletion of licenses
2026-05-12 13:55:37 +01:00
snipe a614f986f0 Added test for the fix I made in the prior commit 2026-05-12 13:07:16 +01:00
snipe f398a59d26 Fixed bug on API delete 2026-05-12 13:05:15 +01:00
snipe c8bd104268 Added comment for clarity 2026-05-12 13:00:51 +01:00
snipe 7f01bd4c56 Merge pull request #19008 from uberbrady/more_exception_handling_in_scim
Widen exception scope in emails; similar treatment for phones
2026-05-12 12:57:06 +01:00
Brady Wetherington 6c3c7fdf49 Widen exception scope in emails; similar treatment for phones 2026-05-12 12:43:53 +01:00
snipe bdc8fc8d4a Eager load license seat relations to avoid n+1 2026-05-12 12:41:45 +01:00
snipe 41be127489 Added bulk license action 2026-05-12 12:41:30 +01:00
snipe fdfae9593d Use isDeletable for delete ability status 2026-05-12 12:41:08 +01:00
snipe 6a21eb53c9 Fixed return type 2026-05-12 12:40:29 +01:00
snipe efde2b4672 Added checknbox to presneter 2026-05-12 12:40:08 +01:00
snipe daaa26cbf4 Added new text strings 2026-05-12 12:34:40 +01:00
snipe 3e7441562c Added bulk action menu 2026-05-12 12:34:31 +01:00
snipe 7b53fa5245 Added bulk delete route 2026-05-12 12:34:16 +01:00
snipe d35d46f5b4 Added tests 2026-05-12 12:34:03 +01:00
Brady Wetherington f4772a9cad Change branch of home-forked SCIM server to re-introduce logging 2026-05-12 12:26:37 +01:00
snipe 4c1bb7e0ac Merge remote-tracking branch 'origin/master' into develop 2026-05-11 19:45:01 +01:00
snipe 762ea9b4db One more try I guess 2026-05-11 19:44:41 +01:00
snipe b16970a61e Merge pull request #18629 from Godmartinz/update-print-invtentory-view-with-assigned2assets
Update print inventory view with indirect assignments table
2026-05-11 18:27:25 +01:00
snipe dadb9bd81e Merge remote-tracking branch 'origin/develop' 2026-05-11 16:12:03 +01:00
snipe 13dc7de660 Add enter to submit advanced search modal 2026-05-11 16:11:51 +01:00
snipe 003ea36e18 Merge remote-tracking branch 'origin/develop' 2026-05-11 16:04:10 +01:00
snipe f4bd2a68c9 Fix for compound is:not_null 2026-05-11 16:03:59 +01:00
snipe be4e75d4f7 Merge remote-tracking branch 'origin/develop' 2026-05-11 15:25:56 +01:00
snipe 538c21ce1e Merge pull request #19002 from grokability/fixed-crash-on-checkout-outside-of-company-id
Fixed crash on checkout outside of company via API
2026-05-11 15:25:27 +01:00
snipe 626cd6cb2e Fixed #18989 - better wrapping for auth self.profile checks 2026-05-11 15:25:03 +01:00
snipe 2a56f6573d Wrap the checkout in a transaction and add tests 2026-05-11 15:07:25 +01:00
snipe 6ee2dc1cd6 Merge pull request #18998 from uberbrady/fix_scim_error_email
Don't 500 on malformed emails input
2026-05-11 14:59:56 +01:00
snipe 3fcde8bd16 Prevent crash when trying to check out a component from another company if FMCS is on 2026-05-11 14:43:54 +01:00
snipe e2ff7a7bc7 Merge pull request #19000 from grokability/reports-index
🎥 Added reports index
2026-05-11 14:37:14 +01:00
snipe c7efd16517 Fixed progressbar color 2026-05-11 14:25:25 +01:00
snipe f2907f04d9 Added nicer border 2026-05-11 14:19:10 +01:00
snipe 7d98c267d5 More formatting tweaks 2026-05-11 14:13:16 +01:00
snipe 5bc6330c13 Messed with the boxes 2026-05-11 14:05:11 +01:00
snipe 1706ed597d Updated text 2026-05-11 14:05:03 +01:00
snipe 6e9ba28ef7 Tightened up query string 2026-05-11 13:26:35 +01:00
snipe 554d1a44de More shifting 2026-05-11 13:21:33 +01:00
snipe c0a8f4c1a4 Include withrashed() 2026-05-11 13:19:49 +01:00
snipe 08be9aac6d Added users chart 2026-05-11 13:08:23 +01:00
Brady Wetherington a51b17fb53 Don't 500 on malformed emails input 2026-05-11 13:07:16 +01:00
snipe 66d5618d60 Fixed links in summary box 2026-05-11 12:59:53 +01:00
snipe e16c2384fd Fixed some dark/light mode stuff 2026-05-11 12:55:25 +01:00
snipe b3323f08a0 More b0xen 2026-05-11 12:29:58 +01:00
snipe 7e63c2ef92 CSS is hard :( 2026-05-11 12:25:38 +01:00
snipe 7f65b6d598 Shifting stuff around again 2026-05-11 12:25:32 +01:00
snipe 8fb8f0a4d2 Edited queries in ReportsController 2026-05-11 12:24:05 +01:00
snipe 637dbc8d2a New strings 2026-05-11 12:23:47 +01:00
snipe 978990fdff Added progressbar 2026-05-11 12:23:41 +01:00
snipe 52a058e511 Breaking everything.. whee 2026-05-11 12:16:15 +01:00
snipe 64bea202c5 Switched layout, added chart 2026-05-11 12:01:45 +01:00
snipe 37f60993ca Added charts with date range picker 2026-05-11 11:45:34 +01:00
snipe 32717c67c7 Added reports index to sidenav 2026-05-11 11:45:20 +01:00
snipe 3681e3f025 Removed extranneous div 2026-05-11 11:45:08 +01:00
snipe 1d0f055349 Added new strings 2026-05-11 11:44:58 +01:00
snipe fb3024ca9c Added controller methods for reports 2026-05-11 11:44:52 +01:00
snipe 005c0ea9f6 Pint 2026-05-11 11:44:37 +01:00
snipe 7c3f1f3a84 Added routes 2026-05-11 11:44:29 +01:00
snipe 900e5209d9 Added claude.md :( 2026-05-11 10:21:30 +01:00
snipe 4fbf416d16 Merge remote-tracking branch 'origin/develop' 2026-05-11 09:56:31 +01:00
snipe 7b7d2c87fb Added League\Csv\EscapeFormula for a few more reports 2026-05-11 09:54:55 +01:00
snipe 6debb3a65d Added is_not: as search modifier 2026-05-11 09:46:05 +01:00
snipe 315ba49a1d Larger tag size 2026-05-11 09:43:42 +01:00
snipe ff57855038 Added EthicalCheck
Giving this a test drive
2026-05-08 17:10:40 +01:00
snipe da6e837578 Merge pull request #18991 from uberbrady/better_scim_errors
Fixed #18987 - fix SCIM error on mismapped fields
2026-05-08 14:25:02 +01:00
snipe a2d8f89162 Merge remote-tracking branch 'origin/develop' 2026-05-08 14:21:20 +01:00
snipe e36d65e695 Use carbon instead 2026-05-08 14:21:07 +01:00
snipe 34abf14cbe Merge remote-tracking branch 'origin/develop' 2026-05-08 14:12:29 +01:00
snipe dda7a4f22f Format dates in custom report 2026-05-08 14:12:15 +01:00
Brady Wetherington 283a885196 Get rid of 'setCode' and just use the constructor parameter instead 2026-05-08 13:15:37 +01:00
snipe d44aa3f16e Merge remote-tracking branch 'origin/develop' 2026-05-07 12:42:15 +01:00
snipe 575e825579 Typo 2026-05-07 12:42:04 +01:00
snipe dc8cbf4786 Stricter FMCS enforcement in API 2026-05-07 12:41:46 +01:00
snipe 5f81a48d8b Merge pull request #18986 from grokability/#18905-asset-location-on-checkin
Fixed #18905 - update location of child assets if parent asset is checked in
2026-05-07 12:32:44 +01:00
snipe c22e4c00a5 Fixed #18905 - update location of child assets if parent asset is checked in 2026-05-07 12:20:25 +01:00
snipe afb37981bf Merge remote-tracking branch 'origin/develop' 2026-05-07 12:07:46 +01:00
snipe 9b5ead39d3 Merge pull request #18985 from grokability/#18959-slack-notification-location
Fixed #18959 - refresh data on checkout notification
2026-05-07 12:06:19 +01:00
snipe 158e66f9c6 Fixed #18959 - refresh data on checkout notification 2026-05-07 11:59:01 +01:00
snipe bd8e944e2f Merge pull request #18967 from marcusmoore/fixes/test-namespace
Fixed namespace for test class
2026-05-07 11:13:31 +01:00
snipe 2b6518427a Merge remote-tracking branch 'origin/develop' 2026-05-07 11:11:16 +01:00
snipe 06d95b679b Merge pull request #18983 from grokability/add-option-to-export-only-assigned-or-unassigned
Allow custom report to filter on assigned/unassigned
2026-05-07 11:11:01 +01:00
snipe ff75b9eed8 Merge pull request #18982 from marcusmoore/consolidate-test-macros
Improved test macros for streamed content
2026-05-07 11:02:28 +01:00
snipe 17a88fcb80 Allow custom report to filter on assigned/unassigned 2026-05-07 11:00:43 +01:00
snipe 185e0073b3 Merge remote-tracking branch 'origin/develop' 2026-05-07 10:40:10 +01:00
snipe eca34de593 Added null-safe operator for components and consumables 2026-05-07 10:39:57 +01:00
snipe d0794ba71c Merge remote-tracking branch 'origin/develop' 2026-05-07 10:37:15 +01:00
snipe 40e89756bf Extend new operators to custom fields 2026-05-07 10:36:59 +01:00
Marcus Moore 55e46b2d15 Improve macro 2026-05-06 12:08:18 -07:00
Marcus Moore 02383aad7b Fix assertSeeTextInStreamedResponse and assertDontSeeTextInStreamedResponse macros
These were previously only checking the first column and not all of the data.
2026-05-06 11:46:28 -07:00
Marcus Moore e75f54cc1c Move helper macros to CustomTestMacros 2026-05-06 11:40:37 -07:00
snipe 1b42e2e138 Merge remote-tracking branch 'origin/develop' 2026-05-06 17:50:59 +01:00
snipe 3668c24d02 Pint again 2026-05-06 17:50:45 +01:00
snipe a84533b4f4 Quick tweak to advanced search aliases 2026-05-06 17:50:33 +01:00
snipe b4efabe82e Merge remote-tracking branch 'origin/develop' 2026-05-06 16:38:06 +01:00
snipe cbe750cc9e Merge pull request #18980 from uberbrady/reduce_scim_error_level
Throw 4xx SCIMExceptions when SCIM clients send bad data
2026-05-06 16:09:53 +01:00
snipe a77dedf3d7 Merge pull request #18979 from uberbrady/improve_saml_nonces
Add new unique constraint and improved nonce-checking logic for SAML
2026-05-06 15:26:39 +01:00
Brady Wetherington b6ce823cc2 Make sure to throw 400-series SCIMExceptions when SCIM clients send bad data 2026-05-06 15:24:58 +01:00
snipe f7e8ce2ade Merge pull request #18969 from grokability/advanced-search-improvements
🎥 Advanced search improvements
2026-05-06 13:02:07 +01:00
snipe 62e5b71dc1 Added loads of comments - this is gnarly stuff 2026-05-06 12:40:40 +01:00
snipe 3d04324595 Added searchableRelationAliases to user model 2026-05-06 12:39:31 +01:00
snipe 468cf73b97 Updated help text 2026-05-06 12:38:15 +01:00
snipe 5b90f9fb87 Switched to templates for readability (still gross, but whatever) 2026-05-06 12:37:57 +01:00
snipe 9131dbf09b Added more filter options 2026-05-06 12:03:55 +01:00
snipe 9b37e95b58 Merge remote-tracking branch 'origin/develop' 2026-05-05 22:00:13 +01:00
snipe a425234365 Fixed typo, added context (“worm”? Really?) 2026-05-05 22:00:02 +01:00
snipe a92d8eeaab Merge remote-tracking branch 'origin/develop' 2026-05-05 20:37:03 +01:00
snipe cd4e268c72 Added/updated tests 2026-05-05 20:36:39 +01:00
snipe b94945a461 Fixed RB-4121 2026-05-05 20:36:31 +01:00
Brady Wetherington 5b0a779c07 Add new unique constraint and improved nonce-checking logic 2026-05-05 15:23:07 +01:00
snipe e8dbb12ccc Merge remote-tracking branch 'origin/develop' 2026-05-05 13:22:59 +01:00
snipe d099bf2983 Merge pull request #18970 from uberbrady/fix_case_sensitive_classname
Change capitalization on SCIMUser; Linux filenames are case-sensitive
2026-05-05 13:22:28 +01:00
Brady Wetherington f7add0e4dd Change capitalization on SCIMUser; Linux filenames are case-sensitive 2026-05-05 13:19:59 +01:00
snipe 1e1cc897ad Added search help 2026-05-05 12:53:30 +01:00
snipe 04e2c59aa9 Typo 2026-05-05 12:40:56 +01:00
snipe 03bd3517be Added ablity to use “not:” or “!” to exclude results 2026-05-05 12:40:42 +01:00
snipe eeba5bc8fd Cleanup 2026-05-05 12:30:25 +01:00
snipe 1f54180c9c Removed highlighting in advanced search 2026-05-05 12:20:36 +01:00
snipe 8497a27c81 Added tags 2026-05-05 11:45:43 +01:00
snipe 80afa470ee Fixed issue where the button classes would get overwritten when closed and the modal re-opened 2026-05-05 11:39:45 +01:00
snipe 10c750e1a2 Added localstorage to handle remembering and/or 2026-05-05 11:33:56 +01:00
snipe 3aa175b36d Added and/or operator 2026-05-05 11:32:08 +01:00
snipe 8a2cd19ea6 Merge remote-tracking branch 'origin/develop' 2026-05-05 10:58:55 +01:00
snipe e76036965b Same for assets 2026-05-05 10:58:43 +01:00
snipe 2bb86a2ec1 Fixed RB-20854 - only allow scalars for users/hardware query strings 2026-05-05 10:52:51 +01:00
Marcus Moore a89c8c6e5b Fix namespace 2026-05-04 13:47:58 -07:00
Marcus Moore 1bdf205ca6 Run pint on test 2026-05-04 13:47:47 -07:00
snipe afdf86ad0d Merge remote-tracking branch 'origin/develop' 2026-05-04 21:47:15 +01:00
snipe ccf801137a Fixed typo 2026-05-04 21:46:47 +01:00
snipe ef746a173e Fixed RB-4120 - Column 'location_id' in where clause is ambiguous 2026-05-04 21:46:40 +01:00
snipe a5dae3f222 Merge remote-tracking branch 'origin/develop' 2026-05-04 20:55:35 +01:00
snipe e3552f4e36 Merge pull request #18966 from uberbrady/scim_php_82_fixes
Switch to PHPv8.2-compatible way of invoking a constructor and a method
2026-05-04 20:41:34 +01:00
snipe 75d9357488 Removed files notes field - confusing and kinda redundant 2026-05-04 20:34:12 +01:00
Brady Wetherington 26c028cf37 Switch to PHPv8.2-compatible way of invoking a constructor and a method 2026-05-04 20:34:01 +01:00
snipe 10c483967f Merge pull request #18965 from grokability/#18952-upload-files-directly-from-create/edit-screen-for-maintenances
🎥 Fixed #18952 - allow non-image files to be uploaded on create/edit maintenances
2026-05-04 20:33:16 +01:00
snipe 07b33e8189 Fixed #18952 - allow non-image files to be uploaded on create/edit maintenances 2026-05-04 20:15:56 +01:00
snipe 97765c08b1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:58:48 +01:00
snipe fc3ea78005 Fixed #18780 - limit height for tall images in info-panel 2026-05-04 19:58:37 +01:00
snipe 6ad92556a1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:36 +01:00
snipe bd4150af5a Merge pull request #18964 from grokability/fixes-for-maintenance-permissions
Fixed #18951 - maintenance permissions
2026-05-04 19:47:15 +01:00
snipe e2465ca2a7 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:03 +01:00
snipe 1c6c93da35 Fixed typo 2026-05-04 19:30:21 +01:00
snipe 0daec32ddd Added dedicated maintenance permission (related right now just to assets) 2026-05-04 19:30:11 +01:00
snipe e466ed9e06 Merge pull request #18184 from uberbrady/use_new_laravel_scim_server
Use new laravel SCIM server
2026-05-04 18:49:44 +01:00
Brady Wetherington 4445b0317f Re-generated lockfile hash with minimal changes 2026-05-04 18:43:20 +01:00
Brady Wetherington beaea6c3bf Merge branch 'develop' into use_new_laravel_scim_server 2026-05-04 18:39:48 +01:00
snipe f5644928a8 Prod assets 2026-05-04 13:24:50 +01:00
snipe a279c44aa5 Pint 2026-05-04 13:23:49 +01:00
snipe f1f96e574c Bumped hash 2026-05-04 13:23:14 +01:00
snipe 1879001ef3 Merge remote-tracking branch 'origin/master' into develop 2026-05-04 13:21:34 +01:00
snipe 5014b1c459 Fixed #18955 - added manufacturer to view-assigned view 2026-05-04 13:21:12 +01:00
snipe 903459cf7e Merge remote-tracking branch 'origin/master' into develop 2026-05-04 13:12:54 +01:00
snipe 7c04661cfa Merge pull request #18963 from grokability/_add-custom-fields-to-eula-pdf
Add custom fields to eula pdf
2026-05-04 13:07:56 +01:00
snipe 76d3194c96 Shifted layout 2026-05-04 12:57:26 +01:00
snipe b63aee2851 Added custom fields to EULA PDFs 2026-05-04 12:51:43 +01:00
snipe f57d2608c5 Fixed #18956 - hide well if no matching graphs are present 2026-05-04 12:20:25 +01:00
snipe 34331525b1 Merge remote-tracking branch 'origin/master' into develop 2026-05-04 12:17:28 +01:00
snipe 8d1f4427ae Merge pull request #18910 from grokability/audit-visibility-fix
Fixed #18896 - Audit visibility fix
2026-05-04 12:14:05 +01:00
snipe 7f89f8284f Merge pull request #18961 from grokability/add-visibility-icons
🖼️ Added visibility icons in custom fields forms
2026-05-04 12:12:56 +01:00
snipe 3b2ac2bc3c Fixed classname 2026-05-04 12:06:42 +01:00
snipe 73e88be8f3 Small cleanup 2026-05-04 12:05:48 +01:00
snipe f5d092f497 Added aria label for accessibility 2026-05-04 11:56:53 +01:00
snipe 8edbad92cb Added visual icons to show where the custom fields will be visible 2026-05-04 11:55:55 +01:00
snipe b0e13a1352 Merge pull request #18946 from marcusmoore/8.5-actions
Added PHP 8.5 to Action tests
2026-04-30 10:03:48 +01:00
snipe 5c75648cd7 Merge remote-tracking branch 'origin/master' into develop 2026-04-28 19:38:44 +01:00
snipe 1872c6eed9 Merge pull request #18950 from grokability/show-hide-password
🎥 Added password toggle JS/HTML
2026-04-28 10:42:21 +01:00
snipe 53199b9737 Added password toggle JS/HTML 2026-04-28 10:35:36 +01:00
snipe 73861c6a04 Merge pull request #18948 from marcusmoore/fixes/index-history-test
Fixed test name
2026-04-27 22:58:34 +01:00
Marcus Moore e2969dd3e2 Fix filename 2026-04-27 13:59:40 -07:00
snipe 0b1b99697e Merge pull request #18947 from spencerrlongg/mobile-client-endpoint
Mobile OAuth Client Endpoint
2026-04-27 20:05:47 +01:00
snipe 07202a8061 Merge pull request #18937 from uberbrady/fix_saml_intended_url
Fix redirecting users to their intended URL's when logging in via SAML
2026-04-27 19:54:09 +01:00
spencerrlongg 189454096b route for mobile client authentication 2026-04-27 13:52:55 -05:00
Marcus Moore 55ee5df852 Merge branch 'develop' into 8.5-actions 2026-04-27 11:52:29 -07:00
snipe f6466b9154 Merge pull request #18945 from marcusmoore/deps/phpspec/prophecy
Bumped phpspec/prophecy to allow installing on PHP 8.5
2026-04-27 19:51:24 +01:00
Marcus Moore 8e5a64dca9 Add php 8.5 to testing workflows 2026-04-27 11:48:26 -07:00
Marcus Moore b894147514 Bump phpspec/prophecy to v1.26.1 2026-04-27 11:27:41 -07:00
snipe d55c2c269f Merge remote-tracking branch 'origin/master' into develop 2026-04-27 19:05:50 +01:00
Brady Wetherington c7afcf0bef Fix returning to intended URL on 2-factor success (or enrollment success) 2026-04-27 14:59:32 +01:00
Brady Wetherington c79f5b8b74 Merge branch 'develop' into use_new_laravel_scim_server 2026-04-27 14:15:27 +01:00
snipe c5296fd76d Fixed selected attribute on multiselect (normal, not select2) for groups 2026-04-27 13:49:11 +01:00
snipe 3cb3284b26 Merge pull request #18942 from grokability/#18939-blank-tag-after-submit
Fixed #18939 - blank audit field in scanner audit screen
2026-04-27 13:22:12 +01:00
snipe d5d0d00ecc Fixed #18939 - blank audit field in scanner audit screen 2026-04-27 13:20:08 +01:00
Brady Wetherington dc6b45cbcb Fix redirecting users to their intended URL's when logging in via SAML 2026-04-23 15:29:28 +01:00
snipe 5db9d67e65 Merge pull request #18936 from grokability/info-panel-button-blade
Refactor show/hide info button into blade component
2026-04-23 14:26:19 +01:00
snipe f64dfa7f92 Refactor show/hide info button into blade component 2026-04-23 14:18:19 +01:00
snipe 06584d17a6 Merge pull request #18874 from marcusmoore/fixes/fd-54740-user-avatar-via-api-master
[FD-54740] Fixed managing user avatar via API
2026-04-23 13:02:36 +01:00
snipe 73bbe5062d Merge remote-tracking branch 'origin/master' into develop 2026-04-22 17:37:29 +01:00
snipe 75cb1041ec Merge pull request #18932 from grokability/small-tweak-to-info-element-status
🎥 Small tweak to info element status
2026-04-22 17:37:15 +01:00
snipe b61ed66d9d Updated logic 2026-04-22 17:25:48 +01:00
snipe 48ebd7faf5 Refine checkin checkout button display 2026-04-22 17:25:26 +01:00
snipe d6de3baa6e Hide button if orphaned 2026-04-22 17:21:33 +01:00
snipe 1be44a4c05 Pint 2026-04-22 17:18:07 +01:00
snipe f17f34f730 Added routes and logging 2026-04-22 17:17:58 +01:00
snipe da5bb6126a Add a warning well if the asset’s checkout is bonked 2026-04-22 17:09:58 +01:00
snipe a5d04d2e65 Added tests 2026-04-22 17:09:34 +01:00
snipe 22d07214fe Sort of fixed #18918 - prevent showing more permissions if user is admin or superadmin 2026-04-22 15:50:27 +01:00
snipe 8d4523d250 Merge pull request #18930 from grokability/#18920-sort-by-eula-in-categories
Added #18920 - sort by EULA in categories
2026-04-22 15:30:55 +01:00
snipe 37a3d694d4 Pint 2026-04-22 15:24:52 +01:00
snipe d21ccdfcbf Added #18920 - sorting by EULA in category view 2026-04-22 15:24:45 +01:00
snipe 11eaf7ce7b Merge remote-tracking branch 'origin/master' into develop 2026-04-22 15:01:22 +01:00
snipe 4eba97d388 Added Armenian as a possible language 2026-04-22 15:01:11 +01:00
snipe 590e97a99f Merge remote-tracking branch 'origin/master' into develop 2026-04-22 14:58:25 +01:00
snipe 613137551a Pint 2026-04-22 14:58:03 +01:00
snipe 23f941c810 Updated language strings 2026-04-22 14:57:16 +01:00
snipe 4c09f3a229 Merge remote-tracking branch 'origin/master' into develop 2026-04-22 14:38:39 +01:00
snipe e7312801ac Fixed division by zero in new label engine 2026-04-22 14:24:11 +01:00
snipe cd69a7ea53 Merge pull request #18927 from grokability/enforce-basenames-on-filenames
Enforce basenames on filenames
2026-04-22 13:42:09 +01:00
snipe 13ffdda12e Piiiint 2026-04-22 13:35:24 +01:00
snipe 372e74aad3 Adds basename to places where we pass a filename 2026-04-22 13:35:16 +01:00
snipe 2a32f7d372 Merge pull request #18926 from grokability/#18172-correctly-decrypt-encrypted-custom-fields-in-custom-asset-report
Fixed #18172 - Encrypted custom fields not correctly decrypted in custom asset report
2026-04-22 12:56:11 +01:00
snipe a2d34cca76 Pint 2026-04-22 12:49:55 +01:00
snipe c513ed5fc3 Fixed #18172 - correctly dencrypt custom fields in custom asset report 2026-04-22 12:49:48 +01:00
snipe 34cd5dcf7c Add @Husky-Devel as a contributor 2026-04-22 12:24:19 +01:00
snipe 260ca085bb Merge pull request #18908 from Husky-Devel/patch-1
Update snipeit.sh script to add support for CentOS/Alma/Redhat 10.x
2026-04-22 12:22:59 +01:00
snipe 7b00074b9e Merge pull request #18915 from Godmartinz/fix-api-label-with-old-engine
Adds translation and better error messaging for API labels
2026-04-22 12:22:33 +01:00
snipe 16e981d99d Merge pull request #18916 from marcusmoore/fixes/fd-54943-acceptance-email
Fixed display of email setting on category show page
2026-04-22 12:21:47 +01:00
snipe 16eb899ba7 Merge pull request #18924 from grokability/#18367-add-option-to-audit-by-serial-in-quickscan
Fixed #18367 - Added option to audit by serial in quickscan audit
2026-04-22 12:21:13 +01:00
snipe 3367f8e5c7 Edited audit method to allow searching by serial 2026-04-22 12:04:27 +01:00
snipe ad635ab95c Updated text 2026-04-22 12:04:11 +01:00
snipe b94e7fd8a0 Added select2 2026-04-22 12:04:04 +01:00
snipe 683fbd7953 Moved nav option 2026-04-22 12:00:41 +01:00
snipe 246ec9e20b Added tests 2026-04-22 11:57:58 +01:00
snipe 81d669d62a Pint 2026-04-22 09:05:01 +01:00
snipe 9ff951d379 Added redundent migration for delete_at 2026-04-22 09:04:53 +01:00
Marcus Moore e327303b3c Fix display of alert_on_response 2026-04-20 16:18:23 -07:00
snipe 21d030db26 Merge pull request #18914 from Godmartinz/rb20958
Fix RB-20958 company display name in custom reports
2026-04-20 20:31:57 +01:00
Godfrey M 444b58504c rename translation 2026-04-20 11:27:56 -07:00
Godfrey M c1e2f4ad75 update api label exception messages to translations 2026-04-20 11:10:41 -07:00
Godfrey M ec6778e770 adds ternary to user company display in custom reports 2026-04-20 10:09:44 -07:00
snipe 1c5d81cb04 Parse through carbon to make suyre the dates match properly 2026-04-20 15:54:45 +01:00
snipe 10e6c93a95 Merge remote-tracking branch 'origin/master' into develop 2026-04-20 15:40:55 +01:00
snipe d37f43daba Merge pull request #18912 from grokability/small-info-panel-tweaks
Small info panel tweaks
2026-04-20 15:40:39 +01:00
snipe dbabd1bab3 Removed stupid space from phpstorm 2026-04-20 15:34:25 +01:00
snipe 6b5398139a Pull date out of the progress bar
This was squishing weird on smaller screens
2026-04-20 15:32:24 +01:00
snipe 0ec45a4fd0 Add text to dates, added class if almost depreciated 2026-04-20 15:31:16 +01:00
snipe b99fd237f3 Use refactored methods 2026-04-20 15:30:55 +01:00
snipe 5d7123eb05 Added tests 2026-04-20 15:30:25 +01:00
snipe 7eb6ebb60d Refactor the percent logic out of the blade 2026-04-20 15:30:16 +01:00
snipe 0060207816 Merge remote-tracking branch 'origin/master' into develop 2026-04-20 14:19:38 +01:00
snipe 5bc273686e Use support_url in manufacturer blade component 2026-04-20 14:18:58 +01:00
snipe 2f6420e05f Pint 2026-04-20 13:57:50 +01:00
snipe c01699b6e4 Skip checking for company_id on models table 2026-04-20 13:57:45 +01:00
snipe 6c6199add8 Narrowed test 2026-04-20 13:40:49 +01:00
snipe 42cd5e0017 Pint :( 2026-04-20 13:30:22 +01:00
snipe baee6a37ea Backfill audit company ID (if one is present) 2026-04-20 13:30:15 +01:00
Peter Gallwas 90b3685808 Add support for CentOS/Alma/Redhat 10.x
Based off the 9.x code add 10.x support, tested on Rocky 10.1
2026-04-20 18:47:03 +12:00
snipe 37a37318aa Merge pull request #18901 from grokability/display-effective-permissions
Display effective permissions
2026-04-17 12:50:58 +01:00
snipe 74e831c4f0 Oh, Pint 2026-04-17 12:36:56 +01:00
snipe be36390b0f Display effective permissions on user view 2026-04-17 12:36:43 +01:00
snipe e9a628066f Merge pull request #18889 from fvollmer/ldap-deletion
Added #14662: Allow (soft) deletion via ldap sync
2026-04-17 12:14:29 +01:00
ArturoSirvent 8f46b5254e Fix backup disk driver configuration for S3 support
- Fix the backup disk in config/filesystems.php to use a dedicated BACKUP_FILESYSTEM_DRIVER env var instead of PRIVATE_FILESYSTEM_DISK
- Add AWS credential fields to the backup disk config so S3 backups work
- Use BACKUP_FILESYSTEM_ROOT with safe default (storage_path('app')) for local driver
- Document BACKUP_FILESYSTEM_DRIVER and BACKUP_FILESYSTEM_ROOT in .env.example

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

Adding it to the startup script should bring no real downsides, but should fix this for all others and all future version of snipe-it that have the same requirement for clearing compiled views
2026-04-13 09:49:51 +02:00
snipe a94ba474f3 Updated version in confog for develop 2026-04-11 11:05:10 +01:00
snipe a81ab0ea0f Merge remote-tracking branch 'origin/master' into develop 2026-04-11 11:04:29 +01:00
snipe 87e65893d3 Updated tests with new text 2026-04-11 10:48:37 +01:00
snipe 405540aea2 Clarified text 2026-04-11 10:38:42 +01:00
snipe ccfebee5f1 Not sure why the timestamps wouldn’t handle this for us, but… 2026-04-11 10:34:03 +01:00
snipe 1d9469a3df Fix action_date on action_logs on bulk checkin and delete 2026-04-11 10:32:04 +01:00
snipe 5417bf3445 Merge pull request #18860 from uberbrady/fix_migrations
Move migration to fire after deleted_at column added to companies
2026-04-09 21:22:58 +01:00
snipe 51ea1327cf Merge pull request #18861 from grokability/log-authed-user-header
Log authed user ID header
2026-04-09 20:16:12 +01:00
Brady Wetherington 8113ddb2d5 Re-Add the old migration as an 'empty migration' just for safety 2026-04-09 20:10:02 +01:00
snipe a88ad35b68 Added token name and ID 2026-04-09 19:35:09 +01:00
snipe 6e60f59265 Changed the name because reasons 2026-04-09 19:24:11 +01:00
snipe a866bfafcd Oh ffs pint 2026-04-09 19:23:29 +01:00
snipe 97d1677568 Check for bearer token in header 2026-04-09 19:23:21 +01:00
snipe f4562db0c0 Pint 2026-04-09 19:19:56 +01:00
snipe a616da3e5c Moved to an API-only header 2026-04-09 19:19:50 +01:00
snipe a895566b02 Pint fixes 2026-04-09 19:09:32 +01:00
snipe 5d75765aae Optionally log the user’s ID in the header 2026-04-09 19:09:21 +01:00
snipe 3bc34fcd5e Added nice icons for revoked/not revoked status 2026-04-09 18:46:18 +01:00
snipe 99c3ac56e9 Fixed typo 2026-04-09 18:32:50 +01:00
Brady Wetherington 95c7d5eeff Move migration to fire _after_ the deleted_at column is added to companies 2026-04-09 18:19:28 +01:00
snipe 371f0b82f6 This is superadmin, do not scope to just the authed user 2026-04-09 18:06:27 +01:00
snipe 114b5d3db0 Updated text 2026-04-09 18:01:44 +01:00
snipe 9ab0f60b41 Merge pull request #18858 from grokability/_api-token-rework
Api token rework
2026-04-09 17:42:09 +01:00
snipe 33b8226ebe A little more cleanup 2026-04-09 17:36:32 +01:00
snipe e062062cb3 Added livewire component for persoinal access tokens for admin 2026-04-09 17:24:51 +01:00
snipe 5f713862fb Updated tests 2026-04-09 17:24:20 +01:00
snipe fe013b5ea0 A bit more polish 2026-04-09 17:20:16 +01:00
snipe 57df2dc2cf Removed extra column 2026-04-09 16:21:39 +01:00
snipe c86fa4c521 Moved tabs 2026-04-09 16:00:49 +01:00
snipe c90acf53d5 One more quick fix for consumables qty percent 2026-04-09 15:14:10 +01:00
snipe fece4d2fdc Handle 0 qty for consumables 2026-04-09 15:10:58 +01:00
snipe f49837e5fe Fix division by zero? 2026-04-09 15:08:46 +01:00
snipe e6ead7c6fa Added tests 2026-04-09 14:52:30 +01:00
snipe 3e84af83d8 Added translations to OAuthClients livewire 2026-04-09 14:52:22 +01:00
snipe aff375c799 Added OAuth table button - I don’t know if this really works properly? 2026-04-09 14:52:01 +01:00
snipe 6ada1a646f Tabbed UI 2026-04-09 14:51:33 +01:00
snipe 42bfd24a8f Expanded OAuth language 2026-04-09 14:49:33 +01:00
snipe c7c6b41dab Added revoke to user model 2026-04-09 14:49:12 +01:00
snipe 449e6b5f5c Added revoke controllers 2026-04-09 14:48:50 +01:00
snipe bbe0c7409f Added TokenRevoked action 2026-04-09 14:48:22 +01:00
snipe 41bb9c378b Added keywords for translation 2026-04-09 14:48:03 +01:00
snipe 9a82b890d4 Translation 2026-04-09 14:47:52 +01:00
snipe 2d2180c9e8 Added revoke/unrevoke routes 2026-04-09 14:26:11 +01:00
snipe 798a590c2a Pint gonna pint 2026-04-09 14:03:37 +01:00
snipe 8a08878062 Added language strings 2026-04-09 14:03:18 +01:00
snipe fde6ff1571 Pint 2026-04-09 12:16:46 +01:00
snipe 8c9a48b38a Fixed custom field search nesting 2026-04-09 12:16:40 +01:00
snipe fff89ee94c Merge pull request #18852 from marcusmoore/fixes/rb-20840-bulk-asset-checkout
Fixed potential exception in bulk asset checkout
2026-04-09 11:28:42 +01:00
Marcus Moore 2745552915 Update asset factory 2026-04-08 14:05:26 -07:00
Marcus Moore 2f400a2b17 Handle target being assigned to a non-user model 2026-04-08 13:40:37 -07:00
Marcus Moore de5256b8f5 Use correct relationship 2026-04-08 13:36:22 -07:00
Marcus Moore 344ae053cf Add failing test 2026-04-08 13:35:11 -07:00
snipe 45fffd74b7 Merge pull request #18846 from grokability/strikethroug-if-component-is-deleted
Strikethrough if component is deleted
2026-04-08 12:47:49 +01:00
snipe a0cf0751de Pint 2026-04-08 12:39:38 +01:00
snipe 7485cb81aa Show strikethrough and unlink if item is deleted 2026-04-08 12:39:29 +01:00
snipe 7faa9a6fdf Fixed #18816 - updated language in acceptance email 2026-04-08 11:24:54 +01:00
snipe f6f7063419 Fixed #18844 - use correct component for bulk editing models on category detail view 2026-04-08 11:17:48 +01:00
snipe 1300fff94c Null safe operator for assets transformer 2026-04-08 11:11:55 +01:00
snipe 5ef9798c68 Pint 2026-04-08 10:09:56 +01:00
snipe db48c18766 Fixed #18840 - added print inventory button back to locations 2026-04-08 10:09:49 +01:00
snipe 880261500b Piiiiiint 2026-04-07 19:14:08 +01:00
snipe e02c257df6 Bumper version 2026-04-07 19:13:58 +01:00
snipe d6b48a2818 Merge pull request #18835 from uberbrady/improve_restore_port_numbers
Fixed #18786 - add port number option to the restore command
2026-04-07 15:48:20 +01:00
Brady Wetherington f8c7eee17b Add port number option to the restore command 2026-04-07 15:40:44 +01:00
snipe 5898205480 Merge pull request #18834 from grokability/asset-components-display-fix
Asset components display fix
2026-04-07 15:32:16 +01:00
snipe c8d2118c74 Merge pull request #18833 from uberbrady/new_indices_for_locations_query
Add new indexes to improve some Location queries
2026-04-07 15:31:32 +01:00
snipe 50676288a1 Pint 2026-04-07 15:29:15 +01:00
snipe db2269092a Moved limit 2026-04-07 15:29:06 +01:00
Brady Wetherington ddaa75a6dd Add new indexes to improve some Location queries 2026-04-07 15:23:31 +01:00
snipe b3f56900e5 Added created_by to sortable fields 2026-04-07 14:46:23 +01:00
snipe f1820b739f Use sum 2026-04-07 14:45:52 +01:00
snipe 56957e28f9 Standardized transformer 2026-04-07 14:45:42 +01:00
snipe 3c32721791 Use ComponentsAssignment model 2026-04-07 14:44:56 +01:00
snipe 2632433cc6 Load location and company on asset load 2026-04-07 14:44:09 +01:00
snipe 16ea577099 Include created_by in pivot 2026-04-07 12:20:25 +01:00
snipe 182e06173d Merge pull request #18730 from marcusmoore/laravel-12-take-2
Upgraded to Laravel 12
2026-04-06 13:03:39 +01:00
snipe f6b4600f8a Added checkout_class alias 2026-04-06 11:24:01 +01:00
snipe 7456c9dce5 Check that $image is not empty 2026-04-06 10:58:12 +01:00
snipe f9e16e16d1 Avoid searching by human readable custom field name to avoid collisions with normal attributes 2026-04-06 10:45:10 +01:00
snipe b42094a1be Merge remote-tracking branch 'origin/develop' 2026-04-06 10:17:38 +01:00
snipe 4c343afec7 Pint 2026-04-06 10:17:29 +01:00
snipe 40b3007676 Removed duplicated custom field search 2026-04-06 10:17:23 +01:00
snipe 48395d162a Merge remote-tracking branch 'origin/develop' 2026-04-06 09:54:45 +01:00
snipe 50aaa54c27 Check status_type for list_all 2026-04-06 09:52:43 +01:00
snipe 47737b082b Missed one in the nav 2026-04-06 09:51:50 +01:00
snipe c4a3c71448 Merge remote-tracking branch 'origin/develop' 2026-04-06 09:49:41 +01:00
snipe 9939849e40 pint 2026-04-06 09:49:33 +01:00
snipe d690989b58 Use status_type instead of status for filtering 2026-04-06 09:49:24 +01:00
snipe d9deb0f30c Merge remote-tracking branch 'origin/develop' 2026-04-06 08:49:05 +01:00
snipe 53ce14dddf Switched to AND operator 2026-04-06 08:48:56 +01:00
snipe 1d0be6261b Merge pull request #18823 from grokability/small-permission-tweaks
Added actions for normalizing permissions input
2026-04-06 08:46:03 +01:00
snipe 108c6eda1d Oh, pint 2026-04-06 08:06:28 +01:00
snipe 6e33bfaf8f Don’t check for filled on groups in user save 2026-04-06 08:06:20 +01:00
snipe a7bc9f0ae9 Use fill() for more compact code 2026-04-05 13:03:37 +01:00
snipe 927e0a4e7b Just set the field directly, since it’s a UI edit 2026-04-05 13:03:02 +01:00
snipe 75b2ac9d33 Aaaaand pint 2026-04-05 13:02:22 +01:00
snipe b0d7ae6f04 Removed redundent display_name setting, since it’s already fillable 2026-04-05 13:02:13 +01:00
snipe c764605d07 Implement the new actions in the controllers 2026-04-05 12:40:34 +01:00
snipe 205cf3cf28 MOAR tests 2026-04-05 12:38:02 +01:00
snipe ea274f0df0 Added tests 2026-04-05 12:37:53 +01:00
snipe 31541c4a56 Sigh. Pint 2026-04-05 12:35:19 +01:00
snipe 2a601ae483 Switched to use actions for normalizing payload 2026-04-05 12:35:09 +01:00
snipe 3fe8600a70 Normalize permissions array 2026-04-05 11:55:08 +01:00
snipe dbd7df2b85 Merge remote-tracking branch 'origin/develop' 2026-04-04 17:09:05 +01:00
snipe 717deb544e Merge pull request #18822 from grokability/fixed-history-api-pagination
Fixed #18821- history api pagination
2026-04-04 17:08:26 +01:00
snipe 51446a5fe0 Pint 2026-04-04 16:59:30 +01:00
snipe 4c4ec3eacc Fixed #18821 - pagination on history 2026-04-04 16:59:23 +01:00
snipe 71b72eae10 Merge remote-tracking branch 'origin/develop' 2026-04-03 15:40:30 +01:00
snipe 01eb585e59 Fixed light-dark button in nav dropdown 2026-04-03 15:40:16 +01:00
snipe 2343841aa1 Merge remote-tracking branch 'origin/develop' 2026-04-03 13:57:31 +01:00
snipe b2790d98d0 Removed codacy badge (for now) 2026-04-03 13:57:20 +01:00
snipe b14e925158 Merge remote-tracking branch 'origin/develop' 2026-04-03 13:46:03 +01:00
snipe 18ef770a85 Fixed RB, added withTrashed() 2026-04-03 13:45:53 +01:00
snipe ee831c9361 Merge remote-tracking branch 'origin/develop' 2026-04-03 13:07:47 +01:00
snipe e446dc1cba Fixed [RB-4105] - check for item’s existance before applying withTrashed() 2026-04-03 13:07:37 +01:00
snipe af283c7e01 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-04-03 12:28:30 +01:00
snipe 4703a8b021 Pint 2026-04-03 12:28:00 +01:00
snipe eb3a608e80 Bumped to pre version 2026-04-03 12:27:50 +01:00
snipe 6fbd189553 Pint 2026-04-03 12:26:49 +01:00
snipe 753e2790ac Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-04-03 12:26:28 +01:00
snipe 2c2de8719b Exclude encrypted custom fields in search 2026-04-03 12:25:09 +01:00
snipe 1e884bf627 Update the alias 2026-04-03 12:19:49 +01:00
snipe 7a001c81ea Merge pull request #18809 from grokability/rename-assetstatus
Rename assetstatus to status (variation of #18808)
2026-04-03 11:38:45 +01:00
snipe 9e82f3ffd9 Merge pull request #18814 from Godmartinz/add-example-logo-to-label-preview
Adds #18663 generic example logo for label preview
2026-04-03 11:37:06 +01:00
snipe e1dc605657 Merge pull request #18820 from grokability/search-one-more-stab
Adds to #18778, fixes for advanced search
2026-04-03 11:36:26 +01:00
snipe bca93b57ec Pint fixes 2026-04-03 11:08:35 +01:00
snipe d929c87bbd Final fixes, tests 2026-04-03 11:08:16 +01:00
snipe 72eb4d6d4d Merge remote-tracking branch 'origin/develop' 2026-04-03 09:52:19 +01:00
snipe 0ccdeed318 Merge pull request #18817 from Godmartinz/notification-language-fix
Fixes #18811  locale for Requestable notifications
2026-04-03 09:41:06 +01:00
snipe 5d6fc9f516 Merge pull request #18818 from marcusmoore/fixes/18798-create-asset-with-scoped-locations
Fixed #18798: creating assets with location for non-super-admins with FMCS
2026-04-03 09:40:31 +01:00
snipe 7f0435e3d6 Merge pull request #18815 from marcusmoore/fixes/18810-acceptance-url-in-mail
Fixed #18810: Display acceptance url in checkout asset email
2026-04-03 09:36:40 +01:00
Marcus Moore 85d7ba73aa Set company_id in request for non-super-admins when fmcs enabled 2026-04-02 15:50:13 -07:00
Godfrey M c6a3afa555 revert change, but still add locale 2026-04-02 15:31:25 -07:00
Godfrey M 6cc8ec63be Correct way to append locale 2026-04-02 15:29:57 -07:00
Godfrey M cf7cb8069b get locale from settings before sending requestable message 2026-04-02 15:22:59 -07:00
Marcus Moore e70519c9c2 Fix test assertion 2026-04-02 13:40:16 -07:00
Marcus Moore 6617cc3e7e Add missing endif 2026-04-02 11:47:27 -07:00
Marcus Moore 3426a427d8 Add more details to failure 2026-04-02 11:30:02 -07:00
Marcus Moore 2e67787d75 Add failing test 2026-04-02 11:28:42 -07:00
snipe 70a30a96fa Try to resolve polymorism for checkout 2026-04-02 19:23:45 +01:00
Godfrey M 8832596aa0 Merge branch 'develop' into add-example-logo-to-label-preview 2026-04-02 11:08:11 -07:00
snipe 0cd013191a Merge pull request #18805 from Godmartinz/fix-company-disabled-css-bug
Fixes #18715 Fixes disabled CSS rules for select2
2026-04-02 19:07:00 +01:00
Marcus Moore 8f70b299cf Merge branch 'develop' into fixes/18798-create-asset-with-scoped-locations 2026-04-02 11:06:42 -07:00
Godfrey M 2c87a469e3 adds generic example logo if no logo present" 2026-04-02 10:55:56 -07:00
snipe 5ca2ec5534 Merge remote-tracking branch 'origin/develop' 2026-04-02 18:12:10 +01:00
snipe 46caf9d9ec Removed phpmd.xml 2026-04-02 18:11:58 +01:00
snipe 7ac0842bc2 Merge remote-tracking branch 'origin/develop' 2026-04-02 17:45:59 +01:00
snipe de1674d001 Removed the display none from bulk asset checkout 2026-04-02 17:45:48 +01:00
Godfrey M 37c30a3079 remove unnecessary css 2026-04-02 09:41:45 -07:00
snipe ce825d1df2 Merge remote-tracking branch 'origin/develop' 2026-04-02 17:39:24 +01:00
snipe b5c01ab820 Fixed #18812 - light/dark on signature page 2026-04-02 17:39:15 +01:00
snipe 8de674c837 Merge remote-tracking branch 'origin/develop' 2026-04-02 17:21:06 +01:00
snipe d289ac7f05 Pint 2026-04-02 11:26:33 +01:00
snipe 7ec60bc6f2 Same as #18808 but renamed assetstatus to status 2026-04-02 11:26:23 +01:00
snipe eb78474d1e Make order number clickable if asset 2026-04-02 10:32:21 +01:00
snipe 6dc5a4a27e Merge remote-tracking branch 'origin/develop' 2026-04-02 09:59:56 +01:00
snipe 10c8045351 Check for file view permissions 2026-04-02 09:59:46 +01:00
snipe 03b6a54fe8 Merge remote-tracking branch 'origin/develop' 2026-04-02 09:36:35 +01:00
snipe 1140795ab8 Fixed #18734 - open markdown link in new window 2026-04-02 09:36:24 +01:00
snipe 0441c07266 Merge remote-tracking branch 'origin/develop' 2026-04-02 09:17:11 +01:00
snipe 64f346a5f0 Aaaand pint 2026-04-02 09:12:03 +01:00
snipe 0ac63a8ac6 Codacy tweaks 2026-04-02 09:11:50 +01:00
snipe f5a3d751da Merge remote-tracking branch 'origin/develop' 2026-04-01 23:32:56 +01:00
snipe 985e7d0c7c Merge pull request #18806 from marcusmoore/fixes/rb-4103manufacturer-url
Fixed RB-4103: Allow more models to access dynamic url presenter method
2026-04-01 23:32:09 +01:00
snipe 97a024b3ec Merge remote-tracking branch 'origin/develop' 2026-04-01 23:29:09 +01:00
snipe c501999676 Merge pull request #18807 from grokability/adds-model-number-to-info-panel-if-asset
Added model number on info-panel if the object is an asset
2026-04-01 23:28:22 +01:00
snipe 80e7cf0b46 Added model number on info-panel if the object is an asset 2026-04-01 23:21:30 +01:00
Marcus Moore b7d2bea3ea Fix dynamic url 2026-04-01 15:15:31 -07:00
Marcus Moore 2acb38a6a5 Move dynamic method to base presenter and adjust for other model types 2026-04-01 15:05:42 -07:00
Godfrey M 8ea7242c38 remove input[type=*]:disabled 2026-04-01 14:45:23 -07:00
Godfrey M 616a93cb52 removed override changes 2026-04-01 14:33:28 -07:00
Godfrey M 7170ea0303 adjust disabled look for select 2 2026-04-01 14:28:32 -07:00
snipe 10eb14776b Merge remote-tracking branch 'origin/develop' 2026-04-01 22:09:03 +01:00
snipe a7b43d1879 Merge pull request #18800 from Godmartinz/acceptance-double-send-bug
Fix #18595 checkout acceptance url bug
2026-04-01 22:08:03 +01:00
snipe 439f3c9c91 Merge remote-tracking branch 'origin/develop' 2026-04-01 21:49:49 +01:00
snipe e2b8368f40 Removed dupe category in info panel 2026-04-01 21:49:38 +01:00
Godfrey M 1243f690c4 changed disabled=true to disabled, adjust css" 2026-04-01 13:48:56 -07:00
snipe e2c8d41a58 Merge pull request #18803 from marcusmoore/fixes/18802-support-url
Fixed #18802: Display dynamic support url for manufacturers properly
2026-04-01 21:46:34 +01:00
snipe 591bba71d5 Pint 2026-04-01 21:46:23 +01:00
snipe 7aef0f78b0 Fixed weird component linking 2026-04-01 21:46:13 +01:00
Marcus Moore b6dc0e2a08 Display dynamic support url properly 2026-04-01 13:37:06 -07:00
Godfrey M 1ed7dd0e1e fix markdown to send messages with Eulas 2026-04-01 13:17:02 -07:00
snipe 5ecfa0b8d8 Merge remote-tracking branch 'origin/develop' 2026-04-01 20:41:09 +01:00
snipe 3578580956 Fix variable 2026-04-01 20:41:00 +01:00
snipe 67457d324c Merge remote-tracking branch 'origin/develop' 2026-04-01 16:25:54 +01:00
snipe 8b4e4aff27 Merge pull request #18801 from grokability/small-improvements-to-activity-report
Small improvements to activity report
2026-04-01 16:25:40 +01:00
snipe eb11c4640b Pint :-/ 2026-04-01 16:16:36 +01:00
snipe 5806fced78 Add optional hide for history 2026-04-01 16:16:25 +01:00
snipe ee0e036354 Removed unused routes 2026-04-01 15:00:16 +01:00
snipe af0ec10e78 Addd notes tab 2026-04-01 15:00:01 +01:00
snipe ccbd73259b Fixed license tabs 2026-04-01 14:58:33 +01:00
snipe 1a44a11b62 Added journal permission 2026-04-01 14:51:57 +01:00
snipe 8502a2291b Allow non-report users to view assets, etc if they have permission 2026-04-01 14:51:42 +01:00
snipe 67ccb5e6d9 Make a generic formatter for class names 2026-04-01 14:51:10 +01:00
snipe 520a70d2ea Added tests 2026-04-01 14:50:56 +01:00
snipe 3dee30c48e Changed button color 2026-04-01 14:50:47 +01:00
snipe f314e12685 Merge remote-tracking branch 'origin/develop' 2026-04-01 10:13:50 +01:00
snipe e3b53c8fa2 Use newer files permission 2026-04-01 10:13:39 +01:00
snipe 4bb5020e0a Added assets obj to asset tab check 2026-04-01 10:13:32 +01:00
snipe 4f1fa95cf9 Check for valid category beofre chekcing for tag color 2026-04-01 10:01:17 +01:00
snipe baeeb8e609 Use shorter auth check 2026-04-01 09:56:41 +01:00
snipe f109ca6f1f Merge remote-tracking branch 'origin/develop' 2026-04-01 09:14:20 +01:00
Marcus Moore 1714d62762 Add failing test 2026-03-31 17:20:40 -07:00
Godfrey M f19ac4d5bb send checkout mail without link or acceptance reference 2026-03-31 16:38:35 -07:00
snipe c6dbccb463 Merge pull request #18799 from marcusmoore/fixes/54576-component-link
Fixed #18797: Fix link to components in asset view
2026-03-31 23:42:24 +01:00
Marcus Moore 80ca2a6d21 Fix link to component 2026-03-31 15:36:24 -07:00
snipe 90c1c8cddd Merge remote-tracking branch 'origin/develop' 2026-03-31 15:28:14 +01:00
snipe 0b6593bdc8 Merge pull request #18783 from ubc-cpsc/fix/aws-sdk-php-PKSA-4t1p-xpk2-nsss
Fixes PKSA-4t1p-xpk2-nsss for aws/aws-sdk-php
2026-03-31 15:26:24 +01:00
snipe 6e8c0e5a14 Merge pull request #18758 from Godmartinz/extends-field-value-if-no-label
Fixes FD-54467 TZe_24mm_E Field value to extend full width
2026-03-31 15:25:53 +01:00
snipe 78c300ea1b Merge pull request #18788 from Godmartinz/fix-bulk-edit-breadcrumb
Fixes bulk edit breadcrumb translation
2026-03-31 15:25:28 +01:00
snipe a3fb492e37 Merge pull request #18790 from spencerrlongg/bug/rm-dead-license-route-rb
Removes Unused License Route
2026-03-31 15:24:21 +01:00
snipe 667f50497c Merge pull request #18791 from spencerrlongg/bug/better-error-reporting-in-custom-rules
Better Error Reporting in Custom Rules
2026-03-31 15:23:53 +01:00
snipe cb93eda4e2 Merge pull request #18792 from spencerrlongg/bug/nest-error-callback-errors-properly
Wrap importer errors in array properly
2026-03-31 15:23:15 +01:00
snipe 613b536f97 Merge remote-tracking branch 'origin/develop' 2026-03-31 13:58:59 +01:00
snipe 1ffaa077e6 Use maintenance buttons on asset view 2026-03-31 13:58:49 +01:00
snipe dbc850550f Merge remote-tracking branch 'origin/develop' 2026-03-31 13:14:30 +01:00
snipe 1efe65e6ba Pint :( 2026-03-31 13:13:43 +01:00
snipe 6a39db7e47 Fixed history return 2026-03-31 13:13:35 +01:00
snipe 43841b8b3c Fixed return type 2026-03-31 10:12:24 +01:00
spencerrlongg c33ab9c924 wrap in array properly 2026-03-30 20:13:52 -05:00
spencerrlongg 135118de65 rm ->getMessage(), report full exception 2026-03-30 19:53:26 -05:00
spencerrlongg b42b9e354f rm dead route for freecheckout endpoint 2026-03-30 19:28:47 -05:00
snipe b59b51b2aa Merge remote-tracking branch 'origin/develop' 2026-03-30 20:17:53 +01:00
snipe 7677b3916d Removed parens 2026-03-30 20:17:43 +01:00
snipe b4debacd1a Merge remote-tracking branch 'origin/develop' 2026-03-30 20:06:48 +01:00
Godfrey M d91c26e718 fix bulk edit breadcrumb translation 2026-03-30 10:38:30 -07:00
snipe 8e64083f06 Removed “in active” class 2026-03-30 14:52:57 +01:00
snipe 56580f117a Fixed audit view tab on assets 2026-03-30 14:52:43 +01:00
snipe 29e994dfd0 Added manufacturer and category relationships 2026-03-30 13:05:57 +01:00
snipe b833daf943 Merge remote-tracking branch 'origin/develop' 2026-03-30 12:15:35 +01:00
snipe 537e09a0a6 Fixed #18779 - added audits tab back in 2026-03-30 12:13:56 +01:00
Joël Pittet f53f55b283 Fixes PKSA-4t1p-xpk2-nsss for aws/aws-sdk-php 2026-03-29 10:03:02 -07:00
snipe 943903d8d6 Merge remote-tracking branch 'origin/develop' 2026-03-28 10:36:48 +00:00
snipe 523920d6d6 Pint 2026-03-28 10:36:37 +00:00
snipe e39a242a76 Ignnore counts 2026-03-28 10:36:29 +00:00
snipe e3b57b0c2f Merge remote-tracking branch 'origin/develop' 2026-03-27 21:10:15 +00:00
snipe 125a9e4031 Merge pull request #18778 from grokability/advanced-search-for-licenses
Advanced search for licenses
2026-03-27 21:09:59 +00:00
snipe 9576871ff9 Pint. Sigh. 2026-03-27 21:02:37 +00:00
snipe 968724f369 Mark test as incomplete in SQLite 2026-03-27 21:02:26 +00:00
snipe 4444a63b92 Added created_at and searchableCounts 2026-03-27 20:39:27 +00:00
snipe 9924112d08 Use Filter request on categories API 2026-03-27 20:38:16 +00:00
snipe 368796c40e Added created_at to category search 2026-03-27 20:37:48 +00:00
snipe b9f6b2bbb8 Made user counts searchable 2026-03-27 20:36:45 +00:00
snipe 86afa9d201 Added advanced search to categories 2026-03-27 20:36:27 +00:00
snipe 294d320aa0 Added test 2026-03-27 20:18:13 +00:00
snipe bdd44061f3 Added ability to support aliased count/sum fields in search 2026-03-27 20:18:05 +00:00
snipe 8545d2d703 Made % remaining sortable 2026-03-27 19:21:55 +00:00
snipe 61f3180d74 Small fixes to Searchable trait 2026-03-27 19:21:41 +00:00
snipe 9efcb09836 Moved adminuser into SnipeModel 2026-03-27 19:21:27 +00:00
snipe 80b7ebd508 Moved adminuser method to the SnipeModel 2026-03-27 19:20:54 +00:00
snipe 4545cf8989 Removed broken(?) use statement 2026-03-27 19:20:11 +00:00
snipe 4dc5e8bbdb Use filter check 2026-03-27 19:19:48 +00:00
snipe 0261776778 Added FilterRequest and added refactorerd search check 2026-03-27 19:19:25 +00:00
snipe 1dfce30a32 Broke out the use statements for readaibility 2026-03-27 19:17:55 +00:00
snipe d7f44fdda4 Added license filter tests 2026-03-27 18:34:54 +00:00
snipe 8facdcd55c Added advanced search back to licenses 2026-03-27 18:34:36 +00:00
snipe 582b8858bc Pint 2026-03-27 18:34:15 +00:00
snipe 6d4264bc58 Refactor Searchable Trait to allow for filters 2026-03-27 18:34:05 +00:00
snipe 340433f418 Merge remote-tracking branch 'origin/develop' 2026-03-27 16:14:39 +00:00
snipe 107576eb01 Merge pull request #18777 from grokability/small-s3-fixes
Fixed #18573 - download URLs for S3, actually force the download
2026-03-27 15:53:48 +00:00
snipe ede406c904 Fixed #18573 - Removed extra slash in files controllers 2026-03-27 15:44:27 +00:00
snipe 3b875ce6ec Actually force the download in S3 2026-03-27 15:43:28 +00:00
snipe c89e14ae52 Removed unused showOrDownloadFile() method 2026-03-27 15:21:49 +00:00
snipe cff2fc0f16 Fixed typo 2026-03-27 13:39:39 +00:00
snipe e8b637b900 Allow qty parameter in partial 2026-03-27 12:59:24 +00:00
snipe 84bb484761 Merge remote-tracking branch 'origin/develop' 2026-03-26 17:50:06 +00:00
snipe 25c8fdd5d6 Fixed typo 2026-03-26 17:49:27 +00:00
snipe 6beaea8be9 Merge remote-tracking branch 'origin/develop' 2026-03-26 17:41:14 +00:00
snipe 7952bdefa8 Pint formatting 2026-03-26 17:40:56 +00:00
snipe 280d16637a Added file-specific policies 2026-03-26 17:40:49 +00:00
snipe cc397f6846 Merge remote-tracking branch 'origin/develop' 2026-03-26 16:27:40 +00:00
snipe bec443ce97 Tweaked checkin/checkout button statuses 2026-03-26 16:27:04 +00:00
snipe 8417007eb8 Fixed #18725 - scope by assetsForShow() 2026-03-26 16:26:33 +00:00
snipe 3db77f05e9 Merge remote-tracking branch 'origin/develop' 2026-03-26 16:05:51 +00:00
snipe 3c1eb27ce1 Merge pull request #18770 from grokability/#18767-added-uploads-for-companies
#18767 added uploads for companies
2026-03-26 16:05:20 +00:00
snipe 614a2cd5de Pint cleanup 2026-03-26 16:02:24 +00:00
snipe 616d0f00f9 Added #18767 - uploads for companies and departments 2026-03-26 16:02:07 +00:00
snipe ef22fb256b Fixed #18768 - people tab on locations 2026-03-26 14:52:43 +00:00
snipe 6e0dbc94d7 Merge remote-tracking branch 'origin/develop' 2026-03-26 13:01:33 +00:00
snipe 328a724920 Fixed #18764 - check for model category in info-panel 2026-03-26 13:01:23 +00:00
snipe f9e620a77f Merge remote-tracking branch 'origin/develop' 2026-03-26 12:57:18 +00:00
snipe 334f27424e Fixed #18765 - viewKeys hiding serial for non-licenses 2026-03-26 12:57:06 +00:00
snipe 45b7df15c3 Merge remote-tracking branch 'origin/develop' 2026-03-26 12:14:34 +00:00
snipe 316f1be3d0 Fixed typo and spacing 2026-03-26 12:14:20 +00:00
snipe a500dd4e9e Add generic history method and component blade for loggables 2026-03-26 12:13:59 +00:00
snipe 4fc35e30c4 Change permissions for maintenances tab 2026-03-26 11:21:31 +00:00
snipe 920676fbd7 Merge remote-tracking branch 'origin/develop' 2026-03-25 14:46:12 +00:00
snipe c2c90dd614 Fixed history count 2026-03-25 14:45:53 +00:00
snipe c69b83da3f Make user tab more flexible 2026-03-25 14:45:53 +00:00
snipe 3d43de0763 Added icons 2026-03-25 14:45:53 +00:00
snipe 413b571ce8 Merge pull request #18737 from guyguy333/public-s3-proxy
Add S3 proxy option
2026-03-25 14:35:56 +00:00
snipe e777d3a54c Merge pull request #18762 from vmikhnevych/debian13installer
Added #18761: Debian 13 support in snipeit.sh installer script
2026-03-25 14:32:17 +00:00
snipe 1981c7daef Merge remote-tracking branch 'origin/develop' 2026-03-25 14:10:22 +00:00
snipe 6a802f9c3c Added padding to pane 2026-03-25 14:09:19 +00:00
snipe f64912e461 Nicer padding in infopanel 2026-03-25 13:57:35 +00:00
snipe 6e3567f0bf Fixed weird BS tables search text local storage issue 2026-03-25 13:56:57 +00:00
snipe 9406b600f9 Formatting 2026-03-25 12:10:07 +00:00
snipe 1398b4cbd6 Small cleanup on the views, added comments to detail view blades 2026-03-25 12:09:56 +00:00
snipe bde097a827 Merge remote-tracking branch 'origin/develop' 2026-03-25 10:39:28 +00:00
snipe a4ad7a0baf Small tweaks to locations API 2026-03-25 10:39:12 +00:00
snipe a3927f25ce Use shorter buttons for opening in maps 2026-03-25 09:52:32 +00:00
snipe d5d8084f95 Remove unused translations 2026-03-25 09:52:19 +00:00
snipe b48fe19617 Added apple and google icon types 2026-03-25 09:51:56 +00:00
snipe f802ea4d38 Fixed tests 2026-03-25 09:20:51 +00:00
vmikhnevych 8107588576 Added #18761: Debian 13 support in snipeit.sh installer script 2026-03-25 10:01:08 +02:00
snipe 531dce4305 Merge remote-tracking branch 'origin/develop' 2026-03-24 23:12:13 +00:00
snipe 44e81dfb8a Fixed typo 2026-03-24 23:11:58 +00:00
snipe b4753e369c Fixed #18732 - use newer datepicker and wire up the today button for today’s date 2026-03-24 23:10:11 +00:00
snipe 7a5842712b 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-03-24 21:41:59 +00:00
snipe b479cdf358 Allow zero or null for accessory qty 2026-03-24 21:40:27 +00:00
snipe 222277de9a Used correct phone string for mobile 2026-03-24 21:38:10 +00:00
snipe e285ee2931 Small fixes in formatting 2026-03-24 21:33:46 +00:00
Godfrey M c4b20a16ce use full width if no field label present on TZe_24mm_E 2026-03-24 10:30:58 -07:00
snipe ae7d967227 Merge pull request #18754 from grokability/modernize-user-view
Modernize user view
2026-03-24 13:30:39 +00:00
snipe 2135efe8a9 Small fixes 2026-03-24 13:25:47 +00:00
snipe 00c617b2b8 Fixed typo 2026-03-24 13:06:09 +00:00
snipe 8dd105a31a Added missing path info 2026-03-24 12:51:18 +00:00
snipe 66aeaaefdb More tab tweaks 2026-03-24 12:48:23 +00:00
snipe eb68789327 Added cost well 2026-03-24 12:41:56 +00:00
snipe d73ab0daa0 Tweaked styles 2026-03-24 12:41:43 +00:00
snipe bce9a91408 Responsive tabs 2026-03-24 12:41:32 +00:00
snipe 43808b00ad Tweaked styles 2026-03-24 12:41:16 +00:00
snipe 17e8ef8e30 Added role to tab nav 2026-03-24 12:40:19 +00:00
snipe d02278930d Pass an alignment to the dl 2026-03-24 12:39:16 +00:00
snipe c2597a788b Hide the info panel toggle on smaller devices 2026-03-24 12:38:59 +00:00
snipe 227afd3965 Changed icon 2026-03-24 12:36:19 +00:00
snipe fc97b68503 Added API key count 2026-03-24 10:41:11 +00:00
snipe d83ec4ea05 Merge pull request #18755 from marcusmoore/fixes/18495-bulk-audit-date
Fixed #18495: properly handle null audit date during bulk audit
2026-03-23 23:13:27 +00:00
Marcus Moore 420bf9162d Populate test cases 2026-03-23 15:54:45 -07:00
Marcus Moore 8b1ec3d54b Improve test names 2026-03-23 15:39:56 -07:00
Marcus Moore ea3d970743 Add a couple sanity tests 2026-03-23 15:39:43 -07:00
Marcus Moore a0c905de33 Handle null next_audit_date 2026-03-23 15:38:57 -07:00
Marcus Moore 3517e040c4 Organization 2026-03-23 14:22:17 -07:00
Marcus Moore cf1fb87b63 Improve assertions 2026-03-23 14:16:59 -07:00
Marcus Moore b2389fb67c Scaffold test cases 2026-03-23 14:16:55 -07:00
Marcus Moore f9f57fb161 Merge branch 'develop' into fixes/18495-bulk-audit-date
# Conflicts:
#	tests/Feature/Assets/Api/AuditAssetTest.php
#	tests/Feature/Assets/Ui/AuditAssetTest.php
#	tests/Feature/Assets/Ui/CloneAssetTest.php
#	tests/Support/Settings.php
2026-03-23 11:49:34 -07:00
Marcus Moore a9f7d42d77 Formatting 2026-03-23 10:51:54 -07:00
Marcus Moore 537861c232 Add auditing group tag to tests 2026-03-23 10:50:12 -07:00
Marcus Moore 6eaea0b73f Merge branch 'develop' into laravel-12-take-2 2026-03-23 10:29:49 -07:00
Marcus Moore ee7dddf836 Update league/commonmark 2026-03-23 10:29:44 -07:00
snipe 4c493efb24 Bulk edit not working on locations now? 2026-03-23 15:00:03 +00:00
snipe 323d308c73 Overhaul user view 2026-03-23 14:59:52 +00:00
snipe 07ddc0e574 Fixed typo 2026-03-23 14:59:41 +00:00
snipe d159f6a3db More copy to clipboard 2026-03-23 14:59:15 +00:00
snipe 1bcfe94818 More icon stuff 2026-03-23 14:58:57 +00:00
snipe f7db8ef03d Fixed typo 2026-03-23 14:58:51 +00:00
snipe 1b2a46d7a0 Made name optional 2026-03-23 14:58:42 +00:00
snipe 0ff2a971a4 Moved new styles into default layout 2026-03-23 14:05:08 +00:00
snipe dce076157b Added new tabs 2026-03-23 14:04:44 +00:00
snipe 7cc0aa336b Added more copy links 2026-03-23 14:04:37 +00:00
snipe 5b193f7a7a Added helper function 2026-03-23 14:04:06 +00:00
snipe e290c70732 Added helper function 2026-03-23 14:03:54 +00:00
snipe a73e68fa1a Added icons 2026-03-23 14:03:45 +00:00
snipe b0578757d2 Merge pull request #18749 from grokability/added-percent-bars
Added percent bars to accessories, etc list views
2026-03-23 10:57:17 +00:00
snipe bf8082f0b9 Formatted tests via pint 2026-03-23 10:48:53 +00:00
snipe 04bebca323 Added unit tests 2026-03-23 10:47:16 +00:00
snipe 0fe753b7da Added % bars to accessories, licenses, etc 2026-03-23 10:41:48 +00:00
snipe 24c3c01851 Merge remote-tracking branch 'origin/develop' 2026-03-23 09:46:13 +00:00
snipe a6a211f386 Check for manufacturer model 2026-03-23 09:46:04 +00:00
snipe 603aa39e3f Merge remote-tracking branch 'origin/develop' 2026-03-23 09:41:07 +00:00
snipe 66607069fe Merge pull request #18740 from grokability/modern-ui-for-assets-view
Modern UI for assets view
2026-03-23 09:40:46 +00:00
snipe 54badc5545 Merge pull request #18748 from grokability/copilot/sub-pr-18740-another-one
[WIP] [WIP] Address feedback on Modern UI for assets view PR
2026-03-23 09:37:05 +00:00
snipe 64c07aa7b6 Merge pull request #18747 from grokability/copilot/sub-pr-18740-again
[WIP] [WIP] Address feedback on Modern UI for assets view PR
2026-03-23 09:36:34 +00:00
copilot-swe-agent[bot] 9d40df179d Guard last_checkout against null before calling diffForHumans
Co-authored-by: snipe <197404+snipe@users.noreply.github.com>
Agent-Logs-Url: https://github.com/grokability/snipe-it/sessions/dfb459ae-6819-47c9-8db5-67d70a8e9e2d
2026-03-23 09:35:35 +00:00
copilot-swe-agent[bot] e9d7189e16 Initial plan 2026-03-23 09:34:43 +00:00
snipe d3fd535605 Merge remote-tracking branch 'origin/develop' 2026-03-23 08:59:36 +00:00
snipe 5992525d8e Merge pull request #18744 from grokability/added-percent-remaining-and-add-asset-button
Added % remaining and create asset button to models view and list
2026-03-23 08:59:23 +00:00
snipe 03b0e24289 Added % remaining and create asset button to models view and list 2026-03-23 08:54:13 +00:00
snipe f7f58ba12d Merge remote-tracking branch 'origin/develop' 2026-03-21 10:59:37 +00:00
snipe 595b5c865f Merge pull request #18742 from grokability/added-armenian-updated-languages
Added Armenian updated languages
2026-03-21 10:59:20 +00:00
snipe 2b2a015f51 Updated language strings 2026-03-21 10:57:56 +00:00
snipe 8160ebf854 Added Amenian 2026-03-21 10:57:28 +00:00
snipe dc27169129 Added Armenian as a language option 2026-03-21 10:47:03 +00:00
snipe 8d7cf50089 Merge remote-tracking branch 'origin/develop' 2026-03-21 10:20:26 +00:00
snipe 8498b9b6bd Use route model binding for department 2026-03-21 10:20:18 +00:00
snipe aead8f6c56 Merge remote-tracking branch 'origin/develop' 2026-03-21 10:03:19 +00:00
snipe d58fda626e Import company model 2026-03-21 10:02:18 +00:00
snipe 0f753ae5b5 Merge pull request #18739 from ubc-cpsc/fix/commonmark-PKSA-21fb-n1x5-5nf7
Fix CVE-2026-33347 and CVE-2026-30838 in league/commonmark
2026-03-21 09:51:01 +00:00
Joël Pittet b91882f5dd Fix commonmark security update 2026-03-20 10:26:26 -07:00
Marcus Moore e2e4743994 Attempt to handle docker build fail 2026-03-19 13:56:44 -07:00
Marcus Moore 7b1a5aea19 Revert "Allow installation when MAIL_FROM_ADDR is not set"
This reverts commit 602e13dab7.
2026-03-19 13:37:44 -07:00
Marcus Moore 602e13dab7 Allow installation when MAIL_FROM_ADDR is not set 2026-03-19 13:31:38 -07:00
Marcus Moore 64117b92b0 Revert "Add default address for docker"
This reverts commit 17c89a3f2b.
2026-03-19 13:19:23 -07:00
Marcus Moore 17c89a3f2b Add default address for docker 2026-03-19 12:07:24 -07:00
Guillaume Delbergue 1482abc8b9 feat: add PUBLIC_S3_PROXY option to serve public uploads through the app
When PUBLIC_S3_PROXY=true, public uploads (images, logos, avatars) are
served through a proxy controller instead of directly from S3. This
allows using a single fully private S3 bucket for all storage, with no
public ACLs or direct S3 URLs exposed to the browser.

The proxy streams files from the configured public disk with proper
cache headers (ETag, Last-Modified, Cache-Control). Disabled by default
for full backward compatibility.
2026-03-19 19:41:05 +01:00
Brady Wetherington 9d33a2c524 Merge branch 'develop' into use_new_laravel_scim_server 2026-03-19 17:41:18 +00:00
snipe 1aef412b13 Merge pull request #18735 from marcusmoore/fixes/54250-assigned-to-in-expiring-assets
Fixed FD-54250: Display assigned entity in expiring assets mail
2026-03-19 16:55:09 +00:00
Marcus Moore f9956cf617 Display assigned to in expiring assets mail 2026-03-19 09:30:47 -07:00
Guillaume Delbergue bc7473d863 feat: use Storage::disk('public')->url() instead of hardcoded upload URLs
Several presenters, models, transformers, and Blade views were building
upload URLs by concatenating config('app.url') with hardcoded '/uploads/'
paths. This only works with local storage and breaks when using S3 or
any non-local public disk. Replaced with Storage::disk('public')->url()
which respects the configured filesystem driver.

Made-with: Cursor
2026-03-19 13:52:36 +01:00
Marcus Moore a470ba76df Remove assertion
Log::setEventDispatcher(Event::fake()) no longer works...
2026-03-18 14:04:17 -07:00
snipe 84c42999e4 Merge remote-tracking branch 'origin/develop' 2026-03-18 20:04:59 +00:00
Marcus Moore 3ce017fa68 Upgrade to Laravel 12
Co-authored-by: Brady Wetherington <bwetherington@grokability.com>
2026-03-18 12:36:16 -07:00
Marcus Moore d446da2243 Update telescope 2026-03-18 12:23:46 -07:00
Marcus Moore cdb4416421 Update collision 2026-03-18 12:21:38 -07:00
Marcus Moore a1de8aa20c Bump phpunit to v11 2026-03-18 12:20:57 -07:00
Marcus Moore adfad90f7c Bump laravel-backup to v9
Co-authored-by: Brady Wetherington <bwetherington@grokability.com>
2026-03-18 12:17:46 -07:00
Marcus Moore 22703806cd Bump larastan to v3 2026-03-18 12:11:20 -07:00
Marcus Moore 22a63fc2ee Bump scim server 2026-03-18 12:10:33 -07:00
Godfrey M 8747ff32dd Merge branch 'develop' into update-print-invtentory-view-with-assigned2assets
# Conflicts:
#	app/Http/Controllers/ProfileController.php
#	app/Http/Controllers/Users/UsersController.php
2026-03-17 16:11:16 -07:00
snipe 218190d989 Merge remote-tracking branch 'origin/develop' 2026-03-17 14:01:35 +00:00
snipe 33402f5e0c Merge remote-tracking branch 'origin/develop' 2026-03-17 13:29:13 +00:00
snipe 0ebd103e21 Merge remote-tracking branch 'origin/develop' 2026-03-17 13:11:20 +00:00
snipe 67f5fb72c3 Merge remote-tracking branch 'origin/develop' 2026-03-17 12:22:34 +00:00
snipe 4568180e85 Merge remote-tracking branch 'origin/develop' 2026-03-17 09:07:00 +00:00
snipe 324c937cc4 Merge remote-tracking branch 'origin/develop' 2026-03-16 21:06:38 +00:00
snipe 93ae07cc89 Merge remote-tracking branch 'origin/develop' 2026-03-16 20:08:56 +00:00
snipe 52a9993b0d Merge remote-tracking branch 'origin/develop' 2026-03-16 10:36:13 +00:00
ArturoSirvent 6145c6cc5a Fix backup disk driver configuration for S3 support
- Fix the backup disk in config/filesystems.php to use a dedicated BACKUP_FILESYSTEM_DRIVER env var instead of PRIVATE_FILESYSTEM_DISK
- Add AWS credential fields to the backup disk config so S3 backups work
- Use BACKUP_FILESYSTEM_ROOT with safe default (storage_path('app')) for local driver
- Document BACKUP_FILESYSTEM_DRIVER and BACKUP_FILESYSTEM_ROOT in .env.example

Fixes #14057
2026-03-14 23:24:58 +01:00
snipe 97854ad02d Bumped hash 2026-03-13 18:27:23 +00:00
snipe 500d6e1f2d Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-03-13 18:25:48 +00:00
snipe 20382ea5bf Merge remote-tracking branch 'origin/develop' 2026-03-12 14:54:28 +00:00
snipe f853d25d4f Merge remote-tracking branch 'origin/develop' 2026-03-12 14:46:48 +00:00
snipe 46e7e12cb2 Merge remote-tracking branch 'origin/develop' 2026-03-12 13:41:06 +00:00
snipe ce3a7bb687 Merge remote-tracking branch 'origin/develop' 2026-03-11 17:57:10 +00:00
snipe 17584e4799 Merge remote-tracking branch 'origin/develop' 2026-03-11 09:33:14 +00:00
snipe 1503f90394 Merge remote-tracking branch 'origin/develop' 2026-03-10 20:26:06 +00:00
snipe d2834fcdb9 Merge remote-tracking branch 'origin/develop' 2026-03-10 20:13:10 +00:00
Godfrey M 4ddd2f1cf8 change indirect Asset name 2026-03-10 12:49:37 -07:00
Godfrey M 11c8fd4d4c update scope for directLicense.category" 2026-03-10 12:41:19 -07:00
Godfrey M ab04f3de93 use inventory scope, add quantity to print blade 2026-03-10 12:35:34 -07:00
Godfrey M 4c16796256 reduce query count to 52 2026-03-10 10:59:59 -07:00
Godfrey M 516771d948 update profile Controller print inventory 2026-03-10 09:50:00 -07:00
snipe 69d7d6aae2 Fixed #18661 - return true/false in JSON 2026-03-10 15:43:13 +00:00
snipe 982766dd77 Merge remote-tracking branch 'origin/develop' 2026-03-10 11:40:14 +00:00
snipe 81cbad52f7 Merge remote-tracking branch 'origin/develop' 2026-03-10 10:25:21 +00:00
snipe f1ef1bc38a Merge remote-tracking branch 'origin/develop' 2026-03-10 10:22:57 +00:00
snipe b696642993 Merge remote-tracking branch 'origin/develop' 2026-03-10 10:14:47 +00:00
snipe e7bb7d3656 Merge remote-tracking branch 'origin/develop' 2026-03-10 09:54:08 +00:00
snipe ffc9e882d7 Merge remote-tracking branch 'origin/develop' 2026-03-08 11:37:38 +00:00
snipe 6e7ff15e78 Merge remote-tracking branch 'origin/develop' 2026-03-07 20:50:31 +00:00
snipe b0c45c7179 Merge remote-tracking branch 'origin/develop' 2026-03-07 11:05:48 +00:00
snipe 0fabc5d88d Merge remote-tracking branch 'origin/develop' 2026-03-06 21:55:59 +00:00
snipe 33ae9f1d5b Merge remote-tracking branch 'origin/develop' 2026-03-06 20:15:11 +00:00
snipe f27aae5e31 Merge remote-tracking branch 'origin/develop' 2026-03-06 14:03:50 +00:00
snipe ff6a6407f5 Merge remote-tracking branch 'origin/develop' 2026-03-06 13:40:16 +00:00
snipe 32a6c8edbe Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-03-06 10:48:34 +00:00
snipe f0d1697108 Merge remote-tracking branch 'origin/develop' 2026-03-06 06:46:08 +00:00
snipe 90cb53566c Merge remote-tracking branch 'origin/develop' 2026-03-06 06:24:38 +00:00
snipe 64982d01cf Merge remote-tracking branch 'origin/develop' 2026-03-06 05:59:23 +00:00
snipe b6cad58917 Merge remote-tracking branch 'origin/develop' 2026-03-06 05:39:20 +00:00
snipe 4f9c952dbe Merge remote-tracking branch 'origin/develop' 2026-03-06 05:22:09 +00:00
snipe b9c3c8954f Merge remote-tracking branch 'origin/develop' 2026-03-06 05:17:51 +00:00
snipe 82b6159475 Merge remote-tracking branch 'origin/develop' 2026-03-06 04:46:33 +00:00
Godfrey M e25ea465c5 add ternary on variables in asset count" 2026-03-04 10:25:59 -08:00
Godfrey M 30ac3d1a26 fix display name of item" 2026-03-03 16:11:46 -08:00
Godfrey M e47c772230 cleaned up other tables in print view 2026-03-03 16:04:13 -08:00
Godfrey M 706b623d95 adds assets to indirect assignment table 2026-03-03 15:51:47 -08:00
Godfrey M a908a76f53 adds components to indirect assignment table 2026-03-03 15:15:45 -08:00
Marcus Moore a026ca92ff Populate tests 2026-03-03 14:46:10 -08:00
Godfrey M a2ec707f79 add licenses to indirect assignedment table 2026-03-03 12:53:37 -08:00
Marcus Moore 1b7fe4f728 Scaffold test cases 2026-03-03 11:07:28 -08:00
Marcus Moore 2bf2d55c6e Fix test case 2026-03-03 11:07:18 -08:00
snipe dbe998d9cf Merge remote-tracking branch 'origin/develop' 2026-03-02 18:36:13 +01:00
Brady Wetherington 7cbc0fa671 Merge branch 'develop' into use_new_laravel_scim_server 2026-02-26 19:53:05 +00:00
snipe 79907a2770 Merge remote-tracking branch 'origin/develop' 2026-02-26 16:04:07 +00:00
snipe 6f60ef9ec2 Merge remote-tracking branch 'origin/develop' 2026-02-25 19:46:20 +00:00
snipe 581867eefc Merge remote-tracking branch 'origin/develop' 2026-02-25 19:05:48 +00:00
snipe 234855f225 Merge remote-tracking branch 'origin/develop' 2026-02-25 18:42:04 +00:00
snipe 0b8176a730 Merge remote-tracking branch 'origin/develop' 2026-02-25 17:41:38 +00:00
snipe d1be571d4d Merge remote-tracking branch 'origin/develop' 2026-02-25 16:41:47 +00:00
snipe d392439f82 Merge remote-tracking branch 'origin/develop' 2026-02-25 14:23:13 +00:00
snipe f423b88b16 Merge remote-tracking branch 'origin/develop' 2026-02-25 12:39:39 +00:00
snipe 853aed5954 Merge remote-tracking branch 'origin/develop' 2026-02-25 12:03:32 +00:00
snipe 947a149d08 Merge remote-tracking branch 'origin/develop' 2026-02-25 11:51:03 +00:00
snipe 3aec52eab0 Merge remote-tracking branch 'origin/develop' 2026-02-24 12:00:15 +00:00
snipe 8d0fda88b7 Tagged 8.4.0 release
# Conflicts:
#	config/version.php
2026-02-23 20:41:11 +00:00
snipe 91a95dbc66 Merge remote-tracking branch 'origin/develop' 2026-02-23 14:44:08 +00:00
snipe a15adc806b Merge remote-tracking branch 'origin/develop' 2026-02-23 14:30:54 +00:00
snipe f328da37bc Merge remote-tracking branch 'origin/develop' 2026-02-23 11:41:54 +00:00
Brady Wetherington 15346eec22 WIP: cleaning up new SCIM config 2026-02-23 11:34:41 +00:00
snipe 3adc8f279b Merge remote-tracking branch 'origin/develop' 2026-02-21 13:17:52 +00:00
snipe 41c75022a9 Merge remote-tracking branch 'origin/develop' 2026-02-21 12:53:42 +00:00
snipe 84924a68b7 Merge remote-tracking branch 'origin/develop' 2026-02-20 15:01:15 +00:00
snipe 5a3a63e0a4 Merge remote-tracking branch 'origin/develop' 2026-02-20 13:10:55 +00:00
snipe 980cc5704f Switched branch name to master 2026-02-20 13:08:23 +00:00
snipe 28054a9112 Merge remote-tracking branch 'origin/develop' 2026-02-20 13:07:33 +00:00
snipe 7a312f5868 Merge remote-tracking branch 'origin/develop' 2026-02-20 12:12:04 +00:00
snipe 5ce493180d Merge remote-tracking branch 'origin/develop' 2026-02-20 11:56:33 +00:00
snipe bbdc78a13c Merge remote-tracking branch 'origin/develop' 2026-02-20 09:36:48 +00:00
snipe 43971b9625 Merge remote-tracking branch 'origin/develop' 2026-02-19 19:15:59 +00:00
snipe f27a3a2c61 Build prod JS assets 2026-02-19 15:13:19 +00:00
snipe b96d0d55c9 Merge remote-tracking branch 'origin/develop' 2026-02-19 15:12:52 +00:00
Brady Wetherington c48e0c7377 Clean out fixme's, standardize on UpdatableComplex 2026-02-19 14:50:02 +00:00
Brady Wetherington 95fdfa6396 Merge branch 'develop' into use_new_laravel_scim_server 2026-02-19 12:41:43 +00:00
snipe f699935f5f Merge remote-tracking branch 'origin/develop' 2026-02-19 12:05:17 +00:00
snipe 8336cf5baa Merge remote-tracking branch 'origin/develop' 2026-02-19 11:42:54 +00:00
snipe d3d90abba7 Merge remote-tracking branch 'origin/develop' 2026-02-19 11:31:05 +00:00
snipe bdaf13da4c Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	resources/views/maintenances/view.blade.php
2026-02-19 11:11:18 +00:00
snipe e92e550e9c Null operator for maintenances 2026-02-18 16:32:40 +00:00
Brady Wetherington f8ecbf8f0b removing Log::error lines 2026-02-12 16:03:40 +00:00
Brady Wetherington c5ffbf6ed9 Merge branch 'develop' into use_new_laravel_scim_server 2026-02-11 12:53:42 +00:00
Brady Wetherington 2115de9926 WIP: move towards UpdatableComplex class for SCIM 2026-02-02 14:55:03 +00:00
Brady Wetherington 53149666ad Merge branch 'develop' into use_new_laravel_scim_server 2026-01-22 19:26:41 +00:00
Brady Wetherington 5d55c5021b Fix last of groups, phone numbers, etc. 2026-01-22 19:16:22 +00:00
Brady Wetherington 778da511a5 Merge branch 'develop' into use_new_laravel_scim_server 2026-01-14 15:38:34 +00:00
Brady Wetherington 84940f12c5 add fix to handling blank emails, add notes on things that look weird 2026-01-14 14:31:16 +00:00
Brady Wetherington 0f45ecc00f Merge branch 'develop' into use_new_laravel_scim_server 2026-01-13 13:31:15 +00:00
Brady Wetherington fc4ac029b1 Added the actual files to make that previous statement true 2025-09-29 12:56:59 +01:00
Brady Wetherington 73f4afa05e Got groups support working in Entra ID 2025-09-29 12:56:23 +01:00
Brady Wetherington ef1a42fff2 Progress! Got addresses updating correctly 2025-09-24 15:57:39 +01:00
Brady Wetherington 760d089073 Azure-specific Manager is handled now 2025-09-23 20:40:21 +01:00
Brady Wetherington 92fbf83bdb Adjusting some Schema settings to match our requirements 2025-09-16 15:41:14 +01:00
Brady Wetherington 9525bbf502 Re-worked the SCIMConfig for the new version of laravel-scim-server 2025-09-16 15:22:33 +01:00
Brady Wetherington 61df3bc462 WIP: switching to new version of laravel-scim-server 2025-09-16 11:44:10 +01:00
2972 changed files with 74559 additions and 20835 deletions
+18
View File
@@ -4262,6 +4262,24 @@
"contributions": [
"code"
]
},
{
"login": "Husky-Devel",
"name": "Peter Gallwas",
"avatar_url": "https://avatars.githubusercontent.com/u/75509373?v=4",
"profile": "https://www.husky.nz",
"contributions": [
"code"
]
},
{
"login": "CybotTM",
"name": "Sebastian Mendel",
"avatar_url": "https://avatars.githubusercontent.com/u/326348?v=4",
"profile": "https://github.com/CybotTM",
"contributions": [
"code"
]
}
]
}
+1 -1
View File
@@ -113,7 +113,7 @@ ENABLE_HSTS=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+2 -1
View File
@@ -37,6 +37,7 @@ MYSQL_ROOT_PASSWORD=changeme1234
DB_PREFIX=null
DB_DUMP_PATH='/usr/bin'
DB_DUMP_SKIP_SSL=true
DB_DUMP_SINGLE_TRANSACTION=false
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci
@@ -120,7 +121,7 @@ ENABLE_HSTS=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+1 -1
View File
@@ -72,7 +72,7 @@ CORS_ALLOWED_ORIGINS="*"
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
# --------------------------------------------
# OPTIONAL: LOGIN THROTTLING
+22 -3
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
@@ -90,7 +91,16 @@ IMAGE_LIB=gd
# --------------------------------------------
# OPTIONAL: BACKUP SETTINGS
# --------------------------------------------
# Backup filesystem configuration
# - BACKUP_FILESYSTEM_DRIVER: Driver to use (local, s3, etc.)
# Default: local (backward compatible)
# Set to s3 to use S3 for backups (requires PRIVATE_AWS_* credentials)
# - BACKUP_FILESYSTEM_ROOT: Root path/prefix
# For local driver: leave commented for default to storage_path("app")
# For S3 driver: empty string = bucket root, or specify prefix like "backups/"
#--------------------------------------------
BACKUP_FILESYSTEM_DRIVER=local
#BACKUP_FILESYSTEM_ROOT=
MAIL_BACKUP_NOTIFICATION_DRIVER=null
MAIL_BACKUP_NOTIFICATION_ADDRESS=null
BACKUP_ENV=true
@@ -124,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
@@ -133,7 +143,7 @@ ENABLE_HSTS=false
# OPTIONAL: CACHE SETTINGS
# --------------------------------------------
CACHE_DRIVER=file
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
@@ -149,6 +159,14 @@ REDIS_PORT=null
MEMCACHED_HOST=null
MEMCACHED_PORT=null
# --------------------------------------------
# OPTIONAL: S3 PROXY
# When enabled, public uploads (images, logos, avatars) are served through
# the application instead of directly from S3. This allows using a single
# fully private S3 bucket for all storage.
# --------------------------------------------
PUBLIC_S3_PROXY=false
# --------------------------------------------
# OPTIONAL: PUBLIC S3 Settings
# --------------------------------------------
@@ -193,6 +211,7 @@ LOGIN_AUTOCOMPLETE=false
RESET_PASSWORD_LINK_EXPIRES=15
PASSWORD_CONFIRM_TIMEOUT=10800
PASSWORD_RESET_MAX_ATTEMPTS_PER_MIN=50
TWO_FACTOR_MAX_ATTEMPTS_PER_MIN=5
INVITE_PASSWORD_LINK_EXPIRES=1500
# --------------------------------------------
+69
View File
@@ -0,0 +1,69 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# EthicalCheck addresses the critical need to continuously security test APIs in development and in production.
# EthicalCheck provides the industrys only free & automated API security testing service that uncovers security vulnerabilities using OWASP API list.
# Developers relies on EthicalCheck to evaluate every update and release, ensuring that no APIs go to production with exploitable vulnerabilities.
# You develop the application and API, we bring complete and continuous security testing to you, accelerating development.
# Know your API and Applications are secure with EthicalCheck our free & automated API security testing service.
# How EthicalCheck works?
# EthicalCheck functions in the following simple steps.
# 1. Security Testing.
# Provide your OpenAPI specification or start with a public Postman collection URL.
# EthicalCheck instantly instrospects your API and creates a map of API endpoints for security testing.
# It then automatically creates hundreds of security tests that are non-intrusive to comprehensively and completely test for authentication, authorizations, and OWASP bugs your API. The tests addresses the OWASP API Security categories including OAuth 2.0, JWT, Rate Limit etc.
# 2. Reporting.
# EthicalCheck generates security test report that includes all the tested endpoints, coverage graph, exceptions, and vulnerabilities.
# Vulnerabilities are fully triaged, it contains CVSS score, severity, endpoint information, and OWASP tagging.
# This is a starter workflow to help you get started with EthicalCheck Actions
name: EthicalCheck-Workflow
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the "master" branch
# Customize trigger events based on your DevSecOps processes.
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: '35 17 * * 6'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
permissions:
contents: read
jobs:
Trigger_EthicalCheck:
permissions:
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
runs-on: ubuntu-latest
steps:
- name: EthicalCheck Free & Automated API Security Testing Service
uses: apisec-inc/ethicalcheck-action@005fac321dd843682b1af6b72f30caaf9952c641
with:
# The OpenAPI Specification URL or Swagger Path or Public Postman collection URL.
oas-url: "http://netbanking.apisec.ai:8080/v2/api-docs"
# The email address to which the penetration test report will be sent.
email: "snipe@snipe.net"
sarif-result-file: "ethicalcheck-results.sarif"
- name: Upload sarif file to repository
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: ./ethicalcheck-results.sarif
+1
View File
@@ -28,6 +28,7 @@ jobs:
- "8.2"
- "8.3"
- "8.4"
- "8.5"
name: PHP ${{ matrix.php-version }}
+1
View File
@@ -24,6 +24,7 @@ jobs:
- "8.2"
- "8.3"
- "8.4"
- "8.5"
name: PHP ${{ matrix.php-version }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
fail-fast: false
matrix:
php-version:
- "8.3"
- "8.5"
name: PHP ${{ matrix.php-version }}
+8 -8
View File
@@ -1,10 +1,10 @@
{
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
"DOC4": "You should really just ignore it and run upgrade.php. Really",
"php_min_version": "8.2.0",
"php_max_major_minor": "8.4",
"php_max_wontwork": "8.5.0",
"current_snipeit_version": "8.0"
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
"DOC4": "You should really just ignore it and run upgrade.php. Really",
"php_min_version": "8.2.0",
"php_max_major_minor": "8.5",
"php_max_wontwork": "8.6.0",
"current_snipeit_version": "8.0"
}
+110
View File
@@ -0,0 +1,110 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Stack
- **PHP 8.2+** / **Laravel 12** (framework), **Laravel Mix** (webpack) for frontend assets
- **AdminLTE 2** / **Bootstrap 3** UI — Blade views, no Livewire/Inertia
- **Chart.js v2.9.4** — bundled at `public/js/dist/Chart.min.js`; use `horizontalBar` type (v2 API, not v3)
## Common Commands
```bash
# Run all tests
php artisan test
# or
vendor/bin/phpunit
# Run a single test file
php artisan test tests/Feature/Assets/AssetsTest.php
# Run a specific test method
php artisan test --filter testSomeMethod
# Build frontend assets (dev)
npm run dev
# Build for production
npm run prod
# Laravel Mix watch
npm run watch
# Tinker / REPL
php artisan tinker
# Clear caches after config/route changes
php artisan optimize:clear
```
Dev server is served via **Laravel Herd** (`herd coverage` for coverage reports).
## Architecture
### Controllers
Two parallel controller trees:
- `app/Http/Controllers/` — web/UI controllers (Blade views)
- `app/Http/Controllers/Api/` — REST API controllers (JSON, used by datatables + select2)
Subdirectory groupings: `Assets/`, `Licenses/`, `Users/`, `Accessories/`, `Consumables/`, `Components/`, `Kits/`, `Account/`, `Auth/`
### API Pattern
Every API controller returns data via a **Transformer** (`app/Http/Transformers/`). Never return raw model attributes from API controllers — always pass through the transformer. `DatatablesTransformer` wraps paginated results.
```php
return (new AssetsTransformer)->transformAssets($assets, $assets->count());
```
### Authorization
All authorization goes through **Policies** (`app/Policies/`). `CheckoutablePermissionsPolicy` is the base for assets/licenses/accessories/consumables — its `checkout()` / `checkin()` methods accept `$item = null` so you can use `@can('checkout', \App\Models\Asset::class)` without an instance.
### FMCS (Full Multiple Company Support)
`Setting::getSettings()->full_multiple_companies_support == '1'` gates company-scoped filtering. The select2 API endpoints (`selectlist()` methods) accept a `companyId` query param — apply it like this:
```php
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$query->where('table.company_id', $request->input('companyId'));
}
```
Pass `data-company-id="{{ $user->company_id }}"` in Blade to wire it to select2.
### Select2 AJAX Dropdowns
Use `class="js-data-ajax"` with `data-endpoint="hardware|licenses|consumables|..."`. `snipeit.js` auto-initializes these, forwarding `data-company-id` as `companyId` and `data-asset-status-type` as `statusType` to the API.
### Routes
All routes are in `routes/web.php` (UI) and `routes/api.php` (API). Breadcrumbs are defined inline using `->breadcrumbs(fn (Trail $trail) => ...)` from `tabuna/breadcrumbs`. Every UI route should have a breadcrumb.
Note: the `reports/unaccepted_assets` route is named with slashes, not dots — use `route('reports/unaccepted_assets')`.
### Translations
String keys live in `resources/lang/en-US/general.php` (and other files in that directory). Always add new UI strings as translation keys rather than hard-coding English.
### Checkout Redirect Flow
After checkout, `Helper::getRedirectOption()` reads `$request->redirect_option`. For redirecting back to the assigned user after checkout:
- Set `redirect_option=target` in the form
- Set `checkout_to_type=user` in the form
- Set `assigned_user={{ $user->id }}` in the form
### Key Helper Methods (`app/Helpers/Helper.php`)
- `Helper::deployableStatusLabelList()` — status labels for checkout forms
- `Helper::defaultChartColors()` — 10-color palette used in charts
- `Helper::getRedirectOption($request, $id, $table)` — post-checkout redirect logic
### Global View Variables
`$snipeSettings` is injected into all views via a service provider — no need to pass `Setting::getSettings()` from every controller. Use it directly in Blade.
## Testing
Tests live in `tests/Feature/` (organized by entity) and `tests/Unit/`. Feature tests hit the database; the test environment uses `array` cache/session/mail drivers. Tests use factories for data setup.
+1 -1
View File
@@ -69,7 +69,7 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") |
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") |
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [<img src="https://avatars.githubusercontent.com/u/2880129?v=4" width="110px;"/><br /><sub>Sebastian Marsching</sub>](http://sebastian.marsching.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [<img src="https://avatars.githubusercontent.com/u/40658372?v=4" width="110px;"/><br /><sub>Mo</sub>](https://github.com/mohammad-ahmadi1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") | [<img src="https://avatars.githubusercontent.com/u/326348?v=4" width="110px;"/><br /><sub>Sebastian Mendel</sub>](https://github.com/CybotTM)<br />[💻](https://github.com/snipe/snipe-it/commits?author=CybotTM "Code") |
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
+1
View File
@@ -56,6 +56,7 @@ COPY --from=mlocati/php-extension-installer:2.1.15 /usr/bin/install-php-extensio
RUN set -eux; \
install-php-extensions \
bcmath \
exif \
gd \
ldap \
mysqli \
+3 -2
View File
@@ -1,13 +1,13 @@
![snipe-it-by-grok](https://github.com/grokability/snipe-it/assets/197404/b515673b-c7c8-4d9a-80f5-9fa58829a602)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/snipe-it/localized.svg)](https://crowdin.com/project/snipe-it) [![Docker Pulls](https://img.shields.io/docker/pulls/snipe/snipe-it.svg)](https://hub.docker.com/r/snipe/snipe-it/) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/804dd1beb14a41f38810ab77d64fc4fc)](https://app.codacy.com/gh/grokability/snipe-it/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Tests in MySQL](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml/badge.svg)](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/snipe-it/localized.svg)](https://crowdin.com/project/snipe-it) [![Docker Pulls](https://img.shields.io/docker/pulls/snipe/snipe-it.svg)](https://hub.docker.com/r/snipe/snipe-it/) [![Tests in MySQL](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml/badge.svg)](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml)
[![All Contributors](https://img.shields.io/badge/all_contributors-331-orange.svg?style=flat-square)](#contributing) [![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://discord.gg/yZFtShAcKk)
## Snipe-IT - Open Source Asset Management System
This is a FOSS project for asset management in IT Operations. Knowing who has which laptop, when it was purchased in order to depreciate it correctly, handling software licenses, etc.
It is built on [Laravel 11](http://laravel.com).
It is built on [Laravel 12](http://laravel.com).
Snipe-IT is actively developed and we [release quite frequently](https://github.com/grokability/snipe-it/releases). ([Check out the live demo here](https://snipeitapp.com/demo/).)
@@ -98,6 +98,7 @@ Since the release of the JSON REST API, several third-party developers have been
- [InQRy (archived)](https://github.com/Microsoft/InQRy) by [@Microsoft](https://github.com/Microsoft)
- [Marksman (archived)](https://github.com/Scope-IT/marksman) - A Windows agent for Snipe-IT
- [Python Module (archived)](https://github.com/jbloomer/SnipeIT-PythonAPI) by [@jbloomer](https://github.com/jbloomer)
[IT-Tools](https://github.com/chrisnox/Snipeit-it-tools) by @chrisnox - Browser bookmarklets for PDF handover/return protocols, digital signatures, label printing (Zebra ZD410), AirWatch MDM sync and Lansweeper CSV import.
We also have a handful of [Google Apps scripts](https://github.com/grokability/google-apps-scripts-for-snipe-it) to help with various tasks.
+15 -4
View File
@@ -10,9 +10,9 @@ however there are times when library dependencies and/or PHP/MySQL dependencies
make it impossible to backport security fixes on older versions.
| Version | Supported |
|---------| ------------------ |
|---------|--------------------|
| 8.x | :white_check_mark: |
| 7.x | :white_check_mark: |
| 7.x | :x: |
| 6.x | :x: |
| 5.1.x | :x: |
| 5.0.x | :x: |
@@ -24,7 +24,18 @@ make it impossible to backport security fixes on older versions.
Security vulnerabilities should be sent to security@snipeitapp.com. You can typically expect a
response within two business days, and we typically have fixes out in under a week from the initial disclosure.
This obviously varies based on the severity of the security issue and the difficulty in remediation,
but those have historically been the timelines we worm around.
This obviously varies based on the severity of the security issue and the difficulty in remediation, but those have
historically been the timelines we work around.
We do ask that you do not disclose the vulnerability publicly until we have had a chance to address it and tag a release
so that we can protect our users, and we will work
with you to coordinate a public disclosure once we have a fix out. We will also work with you to ensure that you receive
appropriate credit for the discovery of the vulnerability, if you would like to be credited. (Please provide a GitHub
username or other information if you would like to be credited, and please let us know if you would like to remain
anonymous.)
For responsible disclosure, we ask that you give us at least __90 days__ to address the issue before disclosing it
publicly,
but we will work with you if you need to disclose it sooner than that.
For a full breakdown of our security policies, please see https://snipeitapp.com/security.
@@ -0,0 +1,109 @@
<?php
namespace App\Actions\Breadcrumbs;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Tabuna\Breadcrumbs\Trail;
final class BuildAcceptanceBreadcrumbs
{
public static function forAcceptance(Trail $trail, CheckoutAcceptance|int|string $acceptance): void
{
$acceptance = self::resolveAcceptance($acceptance);
$trail->parent('home');
if (! $acceptance instanceof CheckoutAcceptance) {
self::appendProfileContext($trail);
return;
}
if (! self::isSignInPlaceFlow($acceptance)) {
self::appendProfileContext($trail);
$trail->push(trans('general.accept_item'), route('account.accept.item', $acceptance));
return;
}
self::appendCheckoutFlowContext($trail, $acceptance);
$trail->push(self::buildSignInPlaceLabel($acceptance));
}
private static function resolveAcceptance(CheckoutAcceptance|int|string $acceptance): ?CheckoutAcceptance
{
if ($acceptance instanceof CheckoutAcceptance) {
return $acceptance;
}
if (is_numeric($acceptance)) {
return CheckoutAcceptance::find((int) $acceptance);
}
return null;
}
private static function isSignInPlaceFlow(CheckoutAcceptance $acceptance): bool
{
return (int) session('sign_in_place_acceptance_id') === (int) $acceptance->id;
}
private static function appendProfileContext(Trail $trail): void
{
$trail->push(trans('general.profile'), route('account'));
$trail->push(trans('general.accept_items'), route('account.accept'));
}
private static function appendCheckoutFlowContext(Trail $trail, CheckoutAcceptance $acceptance): void
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof Asset) {
$trail->push(trans('general.assets'), route('hardware.index'));
$trail->push($checkoutable->display_name ?? trans('general.asset'), route('hardware.show', $checkoutable));
$trail->push(trans('general.checkout'));
return;
}
if ($checkoutable instanceof LicenseSeat) {
$license = $checkoutable->license;
if ($license instanceof License) {
$trail->push(trans('general.licenses'), route('licenses.index'));
$trail->push($license->display_name ?? trans('general.license'), route('licenses.show', $license));
$trail->push(trans('general.checkout'));
}
return;
}
if ($checkoutable instanceof Consumable) {
$trail->push(trans('general.consumables'), route('consumables.index'));
$trail->push($checkoutable->display_name ?? trans('general.consumable'), route('consumables.show', $checkoutable));
$trail->push(trans('general.checkout'));
return;
}
if ($checkoutable instanceof Accessory) {
$trail->push(trans('general.accessories'), route('accessories.index'));
$trail->push($checkoutable->display_name ?? trans('general.accessory'), route('accessories.show', $checkoutable));
$trail->push(trans('general.checkout'));
}
}
private static function buildSignInPlaceLabel(CheckoutAcceptance $acceptance): string
{
if ($acceptance->assignedTo instanceof User) {
return sprintf('%s for %s', trans('general.sign_in_place'), $acceptance->assignedTo->display_name);
}
return trans('general.sign_in_place');
}
}
@@ -44,7 +44,7 @@ class CreateCheckoutRequestAction
$asset->request();
$asset->increment('requests_counter', 1);
try {
$settings->notify(new RequestAssetNotification($data));
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
} catch (\Exception $e) {
Log::warning($e);
}
@@ -0,0 +1,30 @@
<?php
namespace App\Actions\Permissions;
final class NormalizePermissionsPayloadAction
{
/**
* Normalize permissions payloads from request/model to a consistent associative array.
*
* @return array<string, mixed>
*/
public static function run(mixed $permissions): array
{
if (is_string($permissions)) {
$decoded = json_decode($permissions, true);
return is_array($decoded) ? $decoded : [];
}
if (is_array($permissions)) {
return $permissions;
}
if ($permissions instanceof \stdClass) {
return (array) $permissions;
}
return [];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Actions\Permissions;
use App\Models\User;
final class PreserveUnauthorizedPrivilegedPermissionsAction
{
/**
* Preserve privileged permission keys unless the authenticated user may manage them.
*
* @param array<string, mixed> $requestedPermissions
* @param array<string, mixed> $originalPermissions
* @return array<string, mixed>
*/
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = [], ?User $targetUser = null): array
{
// Disallow non-admin/superuser users from modifying their own permissions, but allow them to modify other users' permissions (except for admin/superuser keys).
if ($targetUser && ! $authenticatedUser->isSuperUser() && $authenticatedUser->id === $targetUser->id) {
return $originalPermissions;
}
if (! $authenticatedUser->isSuperUser()) {
if (array_key_exists('superuser', $originalPermissions)) {
$requestedPermissions['superuser'] = $originalPermissions['superuser'];
} else {
unset($requestedPermissions['superuser']);
}
}
if ((! $authenticatedUser->isAdmin()) && (! $authenticatedUser->isSuperUser())) {
if (array_key_exists('admin', $originalPermissions)) {
$requestedPermissions['admin'] = $originalPermissions['admin'];
} else {
unset($requestedPermissions['admin']);
}
}
return $requestedPermissions;
}
}
+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);
}
}
}
@@ -0,0 +1,308 @@
<?php
namespace App\Console\Commands;
use App\Events\CheckoutableCheckedIn;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Component;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class CheckinAndDeleteItems extends Command
{
protected $signature = 'snipeit:checkin-delete-all
{--company-id= : Only process items belonging to this company ID}
{--admin-id= : ID of the user credited for the checkins (defaults to first superadmin)}
{--no-notifications : Suppress email and webhook notifications}
{--type=all : Comma-separated types to process: assets, licenses, accessories, components, or all}
{--note= : Note recorded on each checkin action log entry}
{--dry-run : Preview what would be processed without making any changes}
{--force : Skip the confirmation prompt}';
protected $description = 'Check in all assigned items and soft-delete them, optionally scoped to a company';
public function handle(): int
{
$companyId = $this->option('company-id');
$noNotifications = $this->option('no-notifications');
$dryRun = $this->option('dry-run');
$typeOption = $this->option('type') ?? 'all';
$note = $this->option('note') ?: 'Checked in and deleted via CLI';
$allTypes = ['assets', 'licenses', 'accessories', 'components'];
$typesToProcess = $typeOption === 'all'
? $allTypes
: array_intersect(array_map('trim', explode(',', $typeOption)), $allTypes);
if (empty($typesToProcess)) {
$this->error('Invalid --type value. Use: assets, licenses, accessories, components, or all.');
return 1;
}
$admin = null;
if (! $dryRun && ! $noNotifications) {
if ($this->option('admin-id')) {
$admin = User::find($this->option('admin-id'));
if (! $admin) {
$this->error('No user found with admin-id '.$this->option('admin-id').'.');
return 1;
}
} else {
$admin = User::onlySuperAdmins()->first();
}
if (! $admin) {
$this->warn('No admin user found — notifications will be suppressed.');
$noNotifications = true;
}
}
$scopeMsg = $companyId ? "company ID {$companyId}" : 'all companies';
$typesMsg = implode(', ', $typesToProcess);
if ($dryRun) {
$this->warn('DRY RUN — no changes will be made.');
} elseif (! $this->option('force')) {
if (! $this->confirm("This will check in and soft-delete all [{$typesMsg}] for [{$scopeMsg}]. Continue?")) {
$this->info('Aborted.');
return 0;
}
}
if (in_array('assets', $typesToProcess)) {
$this->processAssets($companyId, $noNotifications, $note, $admin, $dryRun);
}
if (in_array('licenses', $typesToProcess)) {
$this->processLicenses($companyId, $noNotifications, $note, $admin, $dryRun);
}
if (in_array('accessories', $typesToProcess)) {
$this->processAccessories($companyId, $noNotifications, $note, $admin, $dryRun);
}
if (in_array('components', $typesToProcess)) {
$this->processComponents($companyId, $noNotifications, $note, $admin, $dryRun);
}
if ($dryRun) {
$this->warn('Dry run complete — no changes were made.');
}
return 0;
}
private function processAssets(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = Asset::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$assets = $query->get();
$checkedIn = 0;
$deleted = 0;
foreach ($assets as $asset) {
if ($asset->assignedTo) {
if ($dryRun) {
$this->line(' Would check in asset: '.$asset->asset_tag.' (assigned to '.$asset->assignedTo->name.')');
} else {
$target = $asset->assignedTo;
$checkin_at = now()->format('Y-m-d H:i:s');
$originalValues = $asset->getRawOriginal();
if ($noNotifications) {
DB::table('assets')->where('id', $asset->id)
->update(['assigned_to' => null, 'assigned_type' => null]);
$asset->logCheckin($target, $note, $checkin_at, $originalValues);
} else {
// Fire event before clearing so the log captures the original state
event(new CheckoutableCheckedIn($asset, $target, $admin, $note, $checkin_at, $originalValues));
DB::table('assets')->where('id', $asset->id)
->update(['assigned_to' => null, 'assigned_type' => null]);
}
$asset->licenseseats()->update(['assigned_to' => null]);
CheckoutAcceptance::pending()
->whereHasMorph('checkoutable', [Asset::class], fn (Builder $q) => $q->where('id', $asset->id))
->delete();
}
$checkedIn++;
}
if ($dryRun) {
$this->line(' Would delete asset: '.$asset->asset_tag);
} else {
$asset->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Assets: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
}
private function processLicenses(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = License::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$licenses = $query->get();
$seatsCheckedIn = 0;
$deleted = 0;
foreach ($licenses as $license) {
$seats = LicenseSeat::where('license_id', $license->id)
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
->get();
foreach ($seats as $seat) {
$target = $seat->assigned_to ? $seat->user : $seat->asset;
if ($dryRun) {
$this->line(' Would check in license seat for: '.$license->name.' (assigned to '.($target?->name ?? $target?->asset_tag ?? 'unknown').')');
} else {
$seat->assigned_to = null;
$seat->asset_id = null;
$seat->save();
if ($target) {
if ($noNotifications) {
$seat->logCheckin($target, $note);
} else {
event(new CheckoutableCheckedIn($seat, $target, $admin, $note));
}
}
}
$seatsCheckedIn++;
}
if ($dryRun) {
$this->line(' Would delete license: '.$license->name);
} else {
$license->licenseseats()->delete();
$license->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Licenses: {$seatsCheckedIn} seats {$action} checked in, {$deleted} licenses {$action} deleted.");
}
private function processAccessories(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = Accessory::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$accessories = $query->get();
$checkedIn = 0;
$deleted = 0;
foreach ($accessories as $accessory) {
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
foreach ($checkouts as $checkout) {
$target = $checkout->assignedTo;
if ($dryRun) {
$this->line(' Would check in accessory: '.$accessory->name.' (assigned to '.($target?->name ?? $target?->asset_tag ?? 'unknown').')');
} else {
$checkin_at = now()->format('Y-m-d H:i:s');
$checkout->delete();
if ($target) {
if ($noNotifications) {
$accessory->logCheckin($target, $note, $checkin_at);
} else {
event(new CheckoutableCheckedIn($accessory, $target, $admin, $note, $checkin_at));
}
}
}
$checkedIn++;
}
if ($dryRun) {
$this->line(' Would delete accessory: '.$accessory->name);
} else {
$accessory->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Accessories: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
}
private function processComponents(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = Component::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$components = $query->get();
$checkedIn = 0;
$deleted = 0;
foreach ($components as $component) {
$assignments = DB::table('components_assets')
->where('component_id', $component->id)
->get();
foreach ($assignments as $assignment) {
$asset = Asset::find($assignment->asset_id);
if ($dryRun) {
$this->line(' Would check in component: '.$component->name.' (assigned to '.($asset?->asset_tag ?? 'unknown').')');
} else {
$checkin_at = now()->format('Y-m-d H:i:s');
DB::table('components_assets')->where('id', $assignment->id)->delete();
if ($asset) {
if ($noNotifications) {
$component->logCheckin($asset, $note, $checkin_at);
} else {
event(new CheckoutableCheckedIn($component, $asset, $admin, $note, $checkin_at));
}
}
}
$checkedIn++;
}
if ($dryRun) {
$this->line(' Would delete component: '.$component->name);
} else {
$component->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Components: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
}
}
@@ -30,41 +30,77 @@ class CleanIncorrectCheckoutAcceptances extends Command
{
$deletions = 0;
$skips = 0;
$total = CheckoutAcceptance::count();
// This walks *every* checkoutacceptance. That's gnarly. But necessary
$this->withProgressBar(CheckoutAcceptance::all(), function ($checkoutAcceptance) use (&$deletions, &$skips) {
$item = $checkoutAcceptance->checkoutable;
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
if (is_null($item)) {
$this->info("'Checkoutable' Item is null, going to next record");
$this->info("Processing {$total} checkout acceptances...");
$bar = $this->output->createProgressBar($total);
$bar->start();
return; // 'false' allegedly breaks execution entirely, so 'true' maybe doesn't? hrm. just straight return maybe?
}
if (get_class($item) == LicenseSeat::class) {
$item = $item->license;
}
foreach ($item->assetlog()->where('action_type', 'checkout')->get() as $assetlog) {
if ($assetlog->target_id == $checkout_to_id && $assetlog->target_type != User::class) {
// We have a checkout-to an ID for a non-User, which matches to an ID in the checkout_acceptances table
// Chunk to avoid loading the whole table into memory; eager-load checkoutable
// to eliminate the N+1 on that relationship.
CheckoutAcceptance::with('checkoutable')
->chunkById(500, function ($chunk) use (&$deletions, &$skips, $bar) {
$idsToDelete = [];
// now, let's compare the _times_ - are they close?
// I'm picking `created_at` over `action_date` because I'm more interested in when the actionlogs
// were _created_, not when they were alleged to have happened - those created_at times need to be within 'X' seconds of
// each other (currently 5)
if ($assetlog->created_at->diffInSeconds($checkoutAcceptance->created_at, true) <= 5) { // we're allowing for five _ish_ seconds of slop
$deletions++;
$checkoutAcceptance->forceDelete(); // HARD delete this record; it should have never been
foreach ($chunk as $checkoutAcceptance) {
$item = $checkoutAcceptance->checkoutable;
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
return;
} else {
// $this->info("The two records are too far apart");
if (is_null($item)) {
$skips++;
$bar->advance();
continue;
}
} else {
// $this->info("No match! checkout to id: " . $checkout_to_id." target_id: ".$assetlog->target_id." target_type: ".$assetlog->target_type);
if (get_class($item) === LicenseSeat::class) {
$item = $item->license;
if (is_null($item)) {
$skips++;
$bar->advance();
continue;
}
}
if (is_null($checkoutAcceptance->created_at)) {
$skips++;
$bar->advance();
continue;
}
// Push all filtering (including the ±5-second window) into the DB;
// exists() returns as soon as one matching row is found rather than
// fetching all checkout logs into PHP.
$shouldDelete = $item->assetlog()
->where('action_type', 'checkout')
->where('target_id', $checkout_to_id)
->where('target_type', '!=', User::class)
->whereBetween('created_at', [
$checkoutAcceptance->created_at->copy()->subSeconds(5),
$checkoutAcceptance->created_at->copy()->addSeconds(5),
])
->exists();
if ($shouldDelete) {
$idsToDelete[] = $checkoutAcceptance->id;
$deletions++;
} else {
$skips++;
}
$bar->advance();
}
}
$skips++;
});
$this->error("Final deletion count: $deletions, and skip count: $skips");
// Bulk-delete the bad records in one query per chunk instead of one per row.
if (! empty($idsToDelete)) {
CheckoutAcceptance::whereIn('id', $idsToDelete)->forceDelete();
}
});
$bar->finish();
$this->newLine();
$this->info("Final deletion count: {$deletions}, and skip count: {$skips}");
}
}
+63 -2
View File
@@ -19,7 +19,7 @@ class LdapSync extends Command
*
* @var string
*/
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--summary} {--json_summary}';
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--delete} {--summary} {--json_summary}';
/**
* The console command description.
@@ -94,6 +94,7 @@ class LdapSync extends Command
}
$summary = [];
$seen_ldap_usernames = [];
try {
@@ -274,8 +275,14 @@ class LdapSync extends Command
'name' => $item['department'],
]);
$user = User::where('username', $item['username'])->first();
$user = User::withTrashed()->where('username', $item['username'])->first();
if (! empty($item['username'])) {
$seen_ldap_usernames[] = $item['username'];
}
if ($user) {
if ($user->trashed()) {
$user->restore();
}
// Updating an existing user.
$item['createorupdate'] = 'updated';
} else {
@@ -490,6 +497,41 @@ class LdapSync extends Command
array_push($summary, $item);
}
// Optionally soft-delete LDAP-imported users that are no longer present in LDAP.
// users with assests etc. are not deletable and skipped
if ($this->option('delete')) {
$missing_ldap_users = User::where('ldap_import', 1);
$missing_ldap_users = $missing_ldap_users->whereNotIn('username', $seen_ldap_usernames);
$missing_ldap_users = $missing_ldap_users->get();
foreach ($missing_ldap_users as $missing_user) {
$is_deletable = $this->isUserDeletable($missing_user);
$missing_item = [
'id' => $missing_user->id,
'username' => $missing_user->username,
'firstname' => $missing_user->first_name,
'lastname' => $missing_user->last_name,
'email' => $missing_user->email,
'createorupdate' => 'skipped',
'status' => 'info',
'deletable' => $is_deletable,
'note' => $is_deletable ? 'missing from LDAP' : 'missing from LDAP, but not deletable',
];
if ($is_deletable) {
$missing_user->delete();
$missing_item['createorupdate'] = 'deleted';
$missing_item['status'] = 'success';
$missing_item['note'] = 'deleted_missing_from_ldap';
}
$summary[] = $missing_item;
}
}
if ($this->option('summary')) {
for ($x = 0; $x < count($summary); $x++) {
if ($summary[$x]['status'] == 'error') {
@@ -505,4 +547,23 @@ class LdapSync extends Command
return $summary;
}
}
/**
* Checks if the user is deletable without gate check
*
* A user is considered deletable if they have no associated assets, accessories, licenses, consumables, managed users, or managed locations.
*
* @param User $user The user to check
*
* @return bool True if the user is deletable, false otherwise
*/
private function isUserDeletable(User $user): bool
{
return (($user->assets_count ?? $user->assets()->count()) === 0)
&& (($user->accessories_count ?? $user->accessories()->count()) === 0)
&& (($user->licenses_count ?? $user->licenses()->count()) === 0)
&& (($user->consumables_count ?? $user->consumables()->count()) === 0)
&& (($user->manages_users_count ?? $user->managesUsers()->count()) === 0)
&& (($user->manages_locations_count ?? $user->managedLocations()->count()) === 0);
}
}
+31 -2
View File
@@ -15,7 +15,9 @@ class PurgeEulaPDFs extends Command
* @var string
*/
protected $signature = 'snipeit:purge-eula-pdfs
{--older-than-days= : The number of days we should delete before }
{--older-than-days= : The number of days we should delete before }
{--company-id= : Only purge acceptances for users in this company}
{--only-deleted-users : Only purge acceptances for deleted users, including soft-deleted or missing users}
{--force : Skip the interactive yes/no prompt for confirmation}
{--dryrun : Show the records that would be deleted but don\'t update the database or delete files from disk}
{--with-output : Display the results in a table in your console}';
@@ -55,7 +57,34 @@ class PurgeEulaPDFs extends Command
$this->info('This script is being run with the --dryrun option. No files or records will be deleted.');
}
$acceptances = CheckoutAcceptance::HasFiles()->where('updated_at', '<', $interval_date)->with('assignedTo')->get();
$companyId = $this->option('company-id');
$query = CheckoutAcceptance::HasFiles()->where('updated_at', '<', $interval_date)
->with([
'assignedTo' => function ($query) {
$query->withTrashed();
},
]);
if ($this->option('only-deleted-users')) {
$query->where(function ($query) use ($companyId) {
$query->whereHas('assignedTo', function ($q) use ($companyId) {
$q->withTrashed()->whereNotNull('deleted_at');
if ($companyId) {
$q->where('company_id', $companyId);
}
});
$query->orWhereDoesntHave('assignedTo');
});
} else {
if ($companyId) {
$query->whereHas('assignedTo', function ($query) use ($companyId) {
$query->withTrashed()->where('company_id', $companyId);
});
}
}
$acceptances = $query->get();
if (! $this->option('force')) {
if ($this->confirm("\n****************************************************\nTHIS WILL DELETE ALL OF THE SIGNATURES AND EULA PDF FILES SINCE $interval_date. \nThere is NO undo! \n****************************************************\n\nDo you wish to continue? No backsies! [y|N]")) {
+40 -1
View File
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
class ResetDemoSettings extends Command
{
@@ -47,7 +48,7 @@ class ResetDemoSettings extends Command
$settings->auto_increment_assets = 1;
$settings->logo = 'snipe-logo.png';
$settings->alert_email = 'service@snipe-it.io';
$settings->login_note = 'Use `admin` / `password` to login to the demo.';
$settings->login_note = "Use any of the following credentials to login to the demo:\n\n- `admin` / `password`\n- `assets` / `password`\n- `testuser` / `password`";
$settings->header_color = '#3c8dbc';
$settings->link_dark_color = '#5fa4cc';
$settings->link_light_color = '#296282;';
@@ -85,6 +86,44 @@ class ResetDemoSettings extends Command
$user->save();
}
$assetsUser = User::updateOrCreate(
['username' => 'assets'],
[
'first_name' => 'Assets',
'last_name' => 'User',
'password' => Hash::make('password'),
'activated' => 1,
]
);
$assetsUser->permissions = json_encode([
'assets.view' => 1,
'assets.create' => 1,
'assets.edit' => 1,
'assets.delete' => 1,
'assets.checkout' => 1,
'assets.checkin' => 1,
'assets.audit' => 1,
'assets.files' => 1,
'assets.view.requestable' => 1,
'assets.view.encrypted_custom_fields' => 1,
]);
$assetsUser->save();
$testUser = User::updateOrCreate(
['username' => 'testuser'],
[
'first_name' => 'Test',
'last_name' => 'User',
'password' => Hash::make('password'),
'activated' => 1,
]
);
$testUser->permissions = json_encode([
'self.checkout_assets' => 1,
'assets.view.requestable' => 1,
]);
$testUser->save();
\Storage::disk('public')->put('snipe-logo.png', file_get_contents(public_path('img/demo/snipe-logo.png')));
\Storage::disk('public')->put('snipe-logo-lg.png', file_get_contents(public_path('img/demo/snipe-logo-lg.png')));
+5 -1
View File
@@ -456,7 +456,11 @@ class RestoreFromBackup extends Command
if (! file_exists($mysql_binary)) {
return $this->error("mysql tool at: '$mysql_binary' does not exist, cannot restore. Please edit DB_DUMP_PATH in your .env to point to a directory that contains the mysqldump and mysql binary");
}
$proc_results = proc_open("$mysql_binary -h ".escapeshellarg(config('database.connections.mysql.host')).' -u '.escapeshellarg(config('database.connections.mysql.username')).' '.escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
$proc_results = proc_open("$mysql_binary -h " .
escapeshellarg(config('database.connections.mysql.host')) .
' -u ' . escapeshellarg(config('database.connections.mysql.username')) . ' ' .
' -P ' . escapeshellarg(config('database.connections.mysql.port')) . ' ' .
escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
null,
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Models\Asset;
use Illuminate\Console\Command;
use Illuminate\Support\MessageBag;
class ValidateAssets extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:validate-assets {--all : Display the valid assets in your table output as well} ';
/**
* The console command description.
*
* @var string
*/
protected $description = 'This runs through the list of assets and checks for any validation errors that would prevent it from being updated or checked in or out. ';
/**
* Execute the console command.
*/
public function handle()
{
$showAll = (bool) $this->option('all');
$assets = Asset::query()
->whereNull('deleted_at')
->with('model')
->orderBy('assets.created_at', 'desc')
->get();
if (! $showAll) {
$this->info('Run this command with the --all option to see the full list in the console.');
}
$rows = $assets
->filter(fn (Asset $asset) => $showAll || ! $asset->isValid())
->map(fn (Asset $asset) => [
trans('general.id') => $asset->id,
trans('admin/hardware/form.tag') => $asset->asset_tag,
trans('admin/hardware/form.serial') => $asset->serial ?? '',
trans('admin/hardware/form.model') => $asset->model?->name ?? '',
trans('general.model_no') => $asset->model?->model_number ?? '',
trans('general.error') => $asset->isValid() ? '√ valid' : $this->formatValidationErrors($asset),
])
->values()
->all();
$this->table(
[
trans('general.id'),
trans('admin/hardware/form.tag'),
trans('admin/hardware/form.serial'),
trans('admin/hardware/form.model'),
trans('general.model_no'),
trans('general.error'),
],
$rows
);
return self::SUCCESS;
}
private function formatValidationErrors(Asset $asset): string
{
$errors = $asset->getErrors();
$messages = [];
if ($errors instanceof MessageBag) {
$messages = $errors->all();
} elseif (is_array($errors)) {
$messages = $errors;
} else {
$messages = [(string) $errors];
}
$prefixedMessages = collect($messages)
->map(fn ($message) => trim((string) $message))
->filter()
->map(fn (string $message) => str_starts_with($message, '✘') ? $message : '✘ '.$message)
->values()
->all();
return implode(PHP_EOL, $prefixedMessages);
}
}
+63 -52
View File
@@ -4,6 +4,9 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use function Laravel\Prompts\info;
use function Laravel\Prompts\select;
class Version extends Command
{
/**
@@ -11,7 +14,7 @@ class Version extends Command
*
* @var string
*/
protected $signature = 'version:update {--branch=master} {--type=patch}';
protected $signature = 'version:update';
/**
* The console command description.
@@ -37,30 +40,40 @@ class Version extends Command
*/
public function handle()
{
$use_branch = $this->option('branch');
$use_type = $this->option('type');
$use_branch = select(
label: 'Which branch?',
options: ['master', 'develop'],
default: 'develop',
);
$use_type = select(
label: 'Which release type?',
options: [
'hash' => 'Hash bump',
'patch' => 'Patch release',
'minor' => 'Minor release',
'major' => 'Major release',
'pre-patch' => 'Pre-patch release',
'pre-minor' => 'Pre-minor release',
'pre-major' => 'Pre-major release',
],
default: 'hash',
scroll: 7,
);
$git_branch = trim(shell_exec('git rev-parse --abbrev-ref HEAD'));
$build_version = trim(shell_exec('git rev-list --count '.$use_branch));
$versionFile = 'config/version.php';
$full_hash_version = str_replace("\n", '', shell_exec('git describe master --tags'));
$version = explode('-', $full_hash_version);
$app_version = $current_app_version = $version[0];
$app_version = $version[0];
$hash_version = (array_key_exists('2', $version)) ? $version[2] : '';
$prerelease_version = '';
$this->line('Branch is: '.$use_branch);
$this->line('Type is: '.$use_type);
$this->line('Current version is: '.$full_hash_version);
if (count($version) == 3) {
$this->line('This does not look like an alpha/beta release.');
} else {
if (array_key_exists('3', $version)) {
$this->line('The current version looks like a beta release.');
$prerelease_version = $version[1];
$hash_version = $version[3];
}
if (array_key_exists('3', $version)) {
$prerelease_version = $version[1];
$hash_version = $version[3];
}
$app_version_raw = explode('.', $app_version);
@@ -74,54 +87,52 @@ class Version extends Command
$patch = $app_version_raw[2];
}
if ($use_type == 'major') {
if ($use_type === 'major') {
$app_version = 'v'.($maj + 1).".$min.$patch";
} elseif ($use_type == 'minor') {
} elseif ($use_type === 'minor') {
$app_version = 'v'."$maj.".($min + 1).".$patch";
} elseif ($use_type == 'pre') {
$pre_raw = str_replace('beta', '', $prerelease_version);
$pre_raw = str_replace('alpha', '', $pre_raw);
$pre_raw = str_ireplace('rc', '', $pre_raw);
$pre_raw = $pre_raw++;
$this->line('Setting the pre-release to '.$prerelease_version.'-'.$pre_raw);
$app_version = 'v'."$maj.".($min + 1).".$patch";
} elseif ($use_type == 'patch') {
} elseif ($use_type === 'pre-patch') {
$app_version = 'v'."$maj.$min.".($patch + 1).'-pre';
} elseif ($use_type === 'pre-minor') {
$app_version = 'v'."$maj.".($min + 1).'.0-pre';
} elseif ($use_type === 'pre-major') {
$app_version = 'v'.($maj + 1).'.0.0-pre';
} elseif ($use_type === 'patch') {
$app_version = 'v'."$maj.$min.".($patch + 1);
// If nothing is passed, leave the version as it is, just increment the build
} else {
$app_version = 'v'."$maj.$min.".$patch;
}
// Determine if this tag already exists, or if this prior to a release
$this->line('Running: git rev-parse master '.$current_app_version);
// $pre_release = trim(shell_exec('git rev-parse '.$use_branch.' '.$current_app_version.' 2>&1 1> /dev/null'));
if ($use_branch == 'develop') {
if ($use_branch === 'develop' && ! str_ends_with($app_version, '-pre')) {
$app_version = $app_version.'-pre';
}
$full_hash_version = str_replace($version[0], $app_version, $full_hash_version);
$full_app_version = $app_version.' - build '.$build_version.'-'.$hash_version;
$array = var_export(
[
'app_version' => $app_version,
'full_app_version' => $full_app_version,
'build_version' => $build_version,
'prerelease_version' => $prerelease_version,
'hash_version' => $hash_version,
'full_hash' => $full_hash_version,
'branch' => $git_branch, ],
true
);
$content = <<<PHP
<?php
// Construct our file content
$content = <<<CON
<?php
return $array;
CON;
return [
'app_version' => '$app_version',
'full_app_version' => '$full_app_version',
'build_version' => '$build_version',
'prerelease_version' => '$prerelease_version',
'hash_version' => '$hash_version',
'full_hash' => '$full_hash_version',
'branch' => '$git_branch',
];
PHP;
// And finally write the file and output the current version
\File::put($versionFile, $content);
$this->info('Setting NEW version: '.$full_app_version.' ('.$git_branch.')');
info('New version: '.$full_app_version.' ('.$git_branch.')');
info('Building JS/CSS assets...');
passthru('npm run prod', $exitCode);
if ($exitCode !== 0) {
$this->error('Asset build failed with exit code '.$exitCode);
} else {
info('Assets built successfully.');
}
}
}
+6
View File
@@ -13,6 +13,7 @@ enum ActionType: string
// Assets/Accessories/Components/Licenses/Consumables
case Checkout = 'checkout';
case CheckinFrom = 'checkin from';
case ForceCheckin = 'force checkin';
case Requested = 'requested';
case RequestCanceled = 'request canceled';
case Accepted = 'accepted';
@@ -23,11 +24,16 @@ enum ActionType: string
// Users
case TwoFactorReset = '2FA reset';
case Merged = 'merged';
case TokenRevoked = 'token revoked';
case TokenUnrevoked = 'token unrevoked';
// Licenses
case DeleteSeats = 'delete seats';
case AddSeats = 'add seats';
// Maintenances
case MaintenanceComplete = 'completed';
// File Uploads
case Uploaded = 'uploaded';
case UploadDeleted = 'upload deleted';
+4 -1
View File
@@ -22,12 +22,14 @@ class CheckoutableCheckedOut
public int $quantity;
public bool $signInPlace;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1)
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1, bool $signInPlace = false)
{
$this->checkoutable = $checkoutable;
$this->checkedOutTo = $checkedOutTo;
@@ -35,5 +37,6 @@ class CheckoutableCheckedOut
$this->note = $note;
$this->originalValues = $originalValues;
$this->quantity = $quantity;
$this->signInPlace = $signInPlace;
}
}
+16 -1
View File
@@ -19,6 +19,8 @@ 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;
@@ -41,6 +43,8 @@ class Handler extends ExceptionHandler
JsonException::class,
SCIMException::class, // these generally don't need to be reported
InvalidFormatException::class,
PublicPropertyNotFoundException::class,
ComponentNotFoundException::class,
];
/**
@@ -71,6 +75,17 @@ class Handler extends ExceptionHandler
public function render($request, Throwable $e)
{
// Livewire tried to set a property that doesn't exist (e.g. stale browser state sending a bare "0" as a property name)
if ($e instanceof PublicPropertyNotFoundException) {
return response()->json(['message' => $e->getMessage()], 422);
}
// 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'));
@@ -201,7 +216,7 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson()) {
return response()->json(['error' => 'Unauthorized or unauthenticated.'], 401);
return response()->json(['error' => trans('general.unauthorized')], 401);
}
return redirect()->guest('login');
+116 -14
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',
@@ -1511,7 +1513,7 @@ class Helper
case 'pt':
return (1 / 72) * static::getUnitConversionFactor('in');
default:
throw new \InvalidArgumentException('Unit: \''.$unit.'\' is not supported');
throw new \InvalidArgumentException('Unit: '.e($unit).' is not supported');
return false;
}
@@ -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') {
@@ -1629,10 +1641,20 @@ class Helper
// return to assignment target
if ($redirect_option == 'target') {
$userId = $request->assigned_user ?? $checkedInFrom;
$locationId = $request->assigned_location ?? $checkedInFrom;
$assetId = $request->assigned_asset ?? $checkedInFrom;
return match ($checkout_to_type) {
'user' => redirect()->route('users.show', $request->assigned_user ?? $checkedInFrom),
'location' => redirect()->route('locations.show', $request->assigned_location ?? $checkedInFrom),
'asset' => redirect()->route('hardware.show', $request->assigned_asset ?? $checkedInFrom),
'user' => $userId
? redirect()->route('users.show', $userId)
: redirect()->route('users.index'),
'location' => $locationId
? redirect()->route('locations.show', $locationId)
: redirect()->route('locations.index'),
'asset' => $assetId
? redirect()->route('hardware.show', $assetId)
: redirect()->route('hardware.index'),
};
}
@@ -1679,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) {
@@ -1713,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,
@@ -1816,6 +1865,8 @@ class Helper
$labelWidth = ($maxLabelWidthPerUnit * $labelSize) + $labelPadding;
$valueX = $currentX + $labelWidth + $gap;
$valueWidth = $usableWidth - $labelWidth - $gap;
$fullValueX = $currentX;
$fullValueWidth = $usableWidth;
return compact(
'scale',
@@ -1829,7 +1880,58 @@ class Helper
'rowAdvance',
'labelWidth',
'valueX',
'valueWidth'
'valueWidth',
'fullValueX',
'fullValueWidth',
);
}
public static function normalizeFullModelName($model): string
{
if (str_contains($model, 'App\\Models\\')) {
return $model;
}
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;
}
}
+23 -4
View File
@@ -7,6 +7,10 @@ class IconHelper
public static function icon($type)
{
switch ($type) {
case 'apple':
return 'fa-brands fa-apple';
case 'google':
return 'fa-brands fa-google';
case 'checkout':
return 'fa-solid fa-rotate-left';
case 'checkin':
@@ -74,6 +78,7 @@ class IconHelper
case 'angle-right':
return 'fas fa-angle-right';
case 'warning':
case 'alert':
return 'fas fa-exclamation-triangle';
case 'kits':
return 'fas fa-object-group';
@@ -116,12 +121,13 @@ class IconHelper
case 'password':
return 'fa-solid fa-key';
case 'api-key':
return 'fa-solid fa-user-secret';
return 'fas fa-user-secret';
case 'nav-toggle':
return 'fas fa-bars';
case 'dashboard':
return 'fas fa-tachometer-alt';
case 'info-circle':
case 'info':
return 'fas fa-info-circle';
case 'caret-right':
return 'fa fa-caret-right';
@@ -139,11 +145,20 @@ class IconHelper
return 'fa-regular fa-clipboard';
case 'paperclip':
return 'fas fa-paperclip';
case 'files':
return 'fa-solid fa-file-contract';
case 'contact-card':
return 'fa-regular fa-id-card';
case 'files':
return 'fa-solid fa-file-contract fa-fw';
case 'eula':
case 'eulas':
return 'fa-regular fa-handshake';
case 'star':
case 'vip':
return 'fa-solid fa-star';
case 'remote':
return 'fa-solid fa-house-laptop';
case 'more-info':
case 'help':
case 'support':
return 'far fa-life-ring';
case 'plus':
@@ -213,7 +228,7 @@ class IconHelper
case 'highlight':
return 'fa-solid fa-highlighter';
case 'manager':
return 'fa-solid fa-building-user';
return 'fa-solid fa-user-tie';
case 'company':
return 'fa-regular fa-building';
case 'parent':
@@ -229,8 +244,10 @@ class IconHelper
case 'terminates':
return 'fa-regular fa-calendar-xmark';
case 'deleted-date':
case 'end_date':
return 'fa-solid fa-calendar-xmark';
case 'expected_checkin':
case 'start_date':
return 'fa-solid fa-calendar-check';
case 'eol':
return 'fa-regular fa-calendar-days';
@@ -252,6 +269,8 @@ class IconHelper
return 'fa-solid fa-file-invoice';
case 'checkout-all':
return 'fa-solid fa-arrows-down-to-people';
case 'checkin-all':
return 'fa-solid fa-arrows-turn-right';
case 'square-right':
return 'fa-regular fa-square-caret-right';
case 'square-left':
+8 -31
View File
@@ -2,7 +2,6 @@
namespace App\Helpers;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -20,7 +19,14 @@ class StorageHelper
return response()->download(Storage::disk($disk)->path($filename)); // works for PRIVATE or public?!
case 's3':
return redirect()->away(Storage::disk($disk)->temporaryUrl($filename, now()->addMinutes(5))); // works for private or public, I guess?
Storage::disk($disk)->temporaryUrl(
$filename,
now()->addMinutes(5),
[
'ResponseContentType' => 'application/octet-stream',
'ResponseContentDisposition' => 'attachment; filename=download-file',
]
);
default:
return Storage::disk($disk)->download($filename);
@@ -119,33 +125,4 @@ class StorageHelper
return null;
}
/**
* Decide whether to show the file inline or download it.
*/
public static function showOrDownloadFile($file, $filename)
{
$headers = [];
if (request('inline') == 'true') {
$headers = [
'Content-Disposition' => 'inline',
];
// This is NOT allowed as inline - force it to be displayed as text in the browser
if (self::allowSafeInline($file) != true) {
$headers = array_merge($headers, ['Content-Type' => 'text/plain']);
}
}
// Everything else seems okay, but the file doesn't exist on the server.
if (Storage::missing($file)) {
throw new FileNotFoundException;
}
return Storage::download($file, $filename, $headers);
}
}
@@ -4,11 +4,12 @@ namespace App\Http\Controllers\Accessories;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
@@ -17,7 +18,7 @@ use Illuminate\Http\Request;
class AccessoryCheckoutController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Return the form to checkout an Accessory to a user.
@@ -65,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++) {
@@ -88,12 +103,53 @@ class AccessoryCheckoutController extends Controller
$request->input('note'),
[],
$accessory->checkout_qty,
$request->boolean('sign_in_place'),
));
$request->request->add(['checkout_to_type' => request('checkout_to_type')]);
$request->request->add(['assigned_to' => $target->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
// When sign_in_place is requested for a user checkout, redirect to the
// acceptance/signature page so the user can sign in person.
if ($request->boolean('sign_in_place') && ! in_array($request->input('checkout_to_type'), ['asset', 'location'], true)) {
$targetUser = User::find($target->id);
if (! $targetUser instanceof User) {
return redirect()->route('accessories.checkout.show', $accessory)
->with('error', trans('admin/accessories/message.checkout.user_does_not_exist'));
}
$acceptance = CheckoutAcceptance::where('checkoutable_type', Accessory::class)
->where('checkoutable_id', $accessory->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($accessory);
$acceptance->assignedTo()->associate($targetUser);
$acceptance->qty = $accessory->checkout_qty;
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $accessory->id,
'sign_in_place_resource_type' => 'Accessories',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/accessories/message.checkout.success'));
}
// Redirect to the new accessory page
return Helper::getRedirectOption($request, $accessory->id, 'Accessories')
@@ -7,8 +7,14 @@ use App\Events\CheckoutDeclined;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Mail\CheckoutAcceptanceResponseMail;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\AcceptanceItemAcceptedNotification;
@@ -40,19 +46,32 @@ class AcceptanceController extends Controller
*
* @param int $id
*/
public function create($id): View|RedirectResponse
public function create(Request $request, $id): View|RedirectResponse
{
$currentUser = auth()->user();
if (! $currentUser instanceof User) {
abort(403, trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (is_null($acceptance)) {
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
}
if (! $acceptance->isPending()) {
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
}
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
}
if (! $acceptance->isCheckedOutTo(auth()->user())) {
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
}
@@ -60,7 +79,10 @@ class AcceptanceController extends Controller
return redirect()->route('account.accept')->with('error', trans('general.error_user_company'));
}
return view('account/accept.create', compact('acceptance'));
$checkedOutAt = Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false);
$checkedOutBy = $this->resolveCheckoutActorName($acceptance);
return view('account/accept.create', compact('acceptance', 'isSignInPlaceAdminFlow', 'checkedOutAt', 'checkedOutBy'));
}
/**
@@ -70,20 +92,36 @@ class AcceptanceController extends Controller
*/
public function store(Request $request, $id): RedirectResponse
{
$currentUser = auth()->user();
if (! $acceptance = CheckoutAcceptance::find($id)) {
if (! $currentUser instanceof User) {
abort(403, trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
}
$assigned_user = User::find($acceptance->assigned_to_id);
$assignedUser = User::find($acceptance->assigned_to_id);
$settings = Setting::getSettings();
$requiresSignature = (string) $settings->require_accept_signature === '1';
$sig_filename = '';
$encodedSignatureImage = null;
if (! $acceptance->isPending()) {
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
}
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
}
if (! $acceptance->isCheckedOutTo(auth()->user())) {
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
}
@@ -111,15 +149,29 @@ class AcceptanceController extends Controller
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
$username_slug = Str::slug($assignedUser->username);
$asset_tag_slug = ($item instanceof Asset && $item->asset_tag) ? '-'.Str::slug($item->asset_tag) : '';
// If signatures are required, make sure we have one
if (Setting::getSettings()->require_accept_signature == '1') {
if ($requiresSignature) {
// The item was accepted, check for a signature
if ($request->filled('signature_output')) {
$sig_filename = 'siglog-'.Str::uuid().'-'.date('Y-m-d-his').'.png';
$data_uri = $request->input('signature_output');
$encoded_image = explode(',', $data_uri);
$decoded_image = base64_decode($encoded_image[1]);
$dataUri = (string) $request->input('signature_output');
$encodedSignatureImage = Str::contains($dataUri, ',')
? Str::after($dataUri, ',')
: $dataUri;
$decoded_image = base64_decode($encodedSignatureImage, true);
if ($decoded_image === false) {
return redirect()->back()->with('error', trans('general.shitty_browser'));
}
$decoded_image = $this->flattenSignatureBackgroundToWhite($decoded_image);
$encodedSignatureImage = base64_encode($decoded_image);
Storage::put('private_uploads/signatures/'.$sig_filename, (string) $decoded_image);
// No image data is present, kick them back.
@@ -133,7 +185,7 @@ class AcceptanceController extends Controller
// This is needed for TCPDF to properly embed the image if it's a png and the cache isn't writable
$encoded_logo = null;
if (($settings->acceptance_pdf_logo) && (Storage::disk('public')->exists($settings->acceptance_pdf_logo))) {
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.$settings->acceptance_pdf_logo));
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.basename($settings->acceptance_pdf_logo)));
}
// Get the data array ready for the notifications and PDF generation
@@ -142,26 +194,50 @@ class AcceptanceController extends Controller
'item_name' => $item->display_name, // this handles licenses seats, which don't have a 'name' field
'item_model' => $item->model?->name,
'item_serial' => $item->serial,
'item_status' => $item->assetstatus?->name,
'item_status' => $item->status?->name,
'eula' => $item->getEula(),
'note' => $request->input('note'),
'check_out_date' => Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false),
'accepted_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
'declined_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
'assigned_to' => $assigned_user->display_name,
'email' => $assigned_user->email,
'employee_num' => $assigned_user->employee_num,
'assigned_to' => $assignedUser->display_name,
'email' => $assignedUser->email,
'employee_num' => $assignedUser->employee_num,
'site_name' => $settings->site_name,
'company_name' => $item->company?->name ?? $settings->site_name,
'signature' => (($sig_filename && array_key_exists('1', $encoded_image))) ? $encoded_image[1] : null,
'signature' => ($sig_filename !== '') ? $encodedSignatureImage : null,
'logo' => ($encoded_logo) ?? null,
'date_settings' => $settings->date_display_format,
'qty' => $acceptance->qty ?? 1,
];
if ($request->input('asset_acceptance') == 'accepted') {
// Include asset custom fields that are explicitly allowed in outbound emails/PDFs.
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
$customFields = [];
$fields = $item->model->fieldset->fields
->where('show_in_email', true)
->where('field_encrypted', false);
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
foreach ($fields as $field) {
$dbColumn = $field->db_column;
$value = $item->{$dbColumn};
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $field->name,
'value' => $value,
];
}
}
if (! empty($customFields)) {
$data['custom_fields'] = $customFields;
}
}
if ($request->input('asset_acceptance') === 'accepted') {
$pdf_filename = 'accepted-'.$username_slug.$asset_tag_slug.'-'.date('Y-m-d-h-i-s').'.pdf';
// Generate the PDF content
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
@@ -171,12 +247,12 @@ class AcceptanceController extends Controller
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
// Send the PDF to the signing user
if (($request->input('send_copy') == '1') && ($assigned_user->email != '')) {
if (($request->input('send_copy') === '1') && ($assignedUser->email !== '')) {
// Add the attachment for the signing user into the $data array
$data['file'] = $pdf_filename;
try {
$assigned_user->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assigned_user->locale));
$assignedUser->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assignedUser->locale));
} catch (Exception $e) {
Log::warning($e);
}
@@ -215,7 +291,7 @@ class AcceptanceController extends Controller
$recipient,
$request->input('asset_acceptance') === 'accepted',
));
Log::debug('Send email notification sucess on checkout acceptance response.');
Log::debug('Send email notification success on checkout acceptance response.');
}
} catch (Exception $e) {
Log::error($e->getMessage());
@@ -223,7 +299,163 @@ class AcceptanceController extends Controller
}
}
if ($isSignInPlaceAdminFlow) {
$request->request->add(['assigned_user' => $assignedUser?->id]);
$redirect = Helper::getRedirectOption(
$request,
session('sign_in_place_item_id'),
session('sign_in_place_resource_type'),
);
session()->forget([
'sign_in_place_acceptance_id',
'sign_in_place_item_id',
'sign_in_place_resource_type',
]);
return $redirect->with('success', $return_msg);
}
return redirect()->to('account/accept')->with('success', $return_msg);
}
private function isSignInPlaceAdminFlow(CheckoutAcceptance $acceptance): bool
{
$currentUser = auth()->user();
return ((int) session('sign_in_place_acceptance_id') === (int) $acceptance->id)
&& ($currentUser?->can('checkout', $acceptance->checkoutable));
}
private function resolveCheckoutActorName(CheckoutAcceptance $acceptance): ?string
{
[$itemType, $itemId] = $this->resolveCheckoutLogItem($acceptance);
$checkoutLog = Actionlog::query()
->where('action_type', 'checkout')
->where('item_type', $itemType)
->where('item_id', $itemId)
->where('target_type', User::class)
->where('target_id', $acceptance->assigned_to_id)
->where('created_at', '<=', $acceptance->created_at->copy()->addMinutes(5))
->latest('id')
->first();
return $checkoutLog?->adminuser?->display_name;
}
/**
* Action logs normalize license seat checkouts to the parent license.
*
* @return array{0: class-string, 1: int}
*/
private function resolveCheckoutLogItem(CheckoutAcceptance $acceptance): array
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof LicenseSeat) {
return [License::class, (int) $checkoutable->license_id];
}
return [$acceptance->checkoutable_type, (int) $acceptance->checkoutable_id];
}
private function isStaleSignInPlaceAdminAttempt(CheckoutAcceptance $acceptance, User $currentUser): bool
{
$redirectOption = session('redirect_option');
$checkoutToType = session('checkout_to_type');
if (session('sign_in_place') !== true) {
return false;
}
if ($redirectOption === null) {
return false;
}
if ($redirectOption === 'target' && $checkoutToType === 'user' && empty($acceptance->assigned_to_id)) {
return false;
}
return ! $acceptance->isCheckedOutTo($currentUser)
&& $currentUser->can('checkout', $acceptance->checkoutable)
&& ($checkoutToType === 'user');
}
private function redirectToIntendedSignInPlaceDestination(Request $request, CheckoutAcceptance $acceptance): RedirectResponse
{
if (empty($acceptance->assigned_to_id)) {
return redirect()->route('account.accept');
}
[$itemId, $resourceType] = $this->resolveRedirectTarget($acceptance);
$request->request->add(['assigned_user' => $acceptance->assigned_to_id]);
return Helper::getRedirectOption($request, $itemId, $resourceType);
}
/**
* @return array{0: int, 1: string}
*/
private function resolveRedirectTarget(CheckoutAcceptance $acceptance): array
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof Asset) {
return [(int) $checkoutable->id, 'Assets'];
}
if ($checkoutable instanceof Accessory) {
return [(int) $checkoutable->id, 'Accessories'];
}
if ($checkoutable instanceof Consumable) {
return [(int) $checkoutable->id, 'Consumables'];
}
if ($checkoutable instanceof LicenseSeat) {
return [(int) $checkoutable->license_id, 'Licenses'];
}
return [(int) $acceptance->checkoutable_id, session('sign_in_place_resource_type', 'Assets')];
}
private function flattenSignatureBackgroundToWhite(string $signatureBinary): string
{
if (! function_exists('imagecreatefromstring') || ! function_exists('imagecreatetruecolor')) {
return $signatureBinary;
}
$source = @imagecreatefromstring($signatureBinary);
if ($source === false) {
return $signatureBinary;
}
$width = imagesx($source);
$height = imagesy($source);
$flattened = imagecreatetruecolor($width, $height);
if ($flattened === false) {
imagedestroy($source);
return $signatureBinary;
}
$white = imagecolorallocate($flattened, 255, 255, 255);
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
imagecopy($flattened, $source, 0, 0, 0, 0, $width, $height);
ob_start();
imagepng($flattened);
$output = ob_get_clean();
imagedestroy($source);
imagedestroy($flattened);
return is_string($output) ? $output : $signatureBinary;
}
}
+6 -2
View File
@@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Helpers\Helper;
use App\Models\Actionlog;
use App\Models\Asset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
@@ -15,6 +14,11 @@ class ActionlogController extends Controller
{
public function displaySig($filename): RedirectResponse|Response|bool
{
$filename = basename((string) $filename);
$actionlog = Actionlog::where('accept_signature', $filename)->with('item')->firstOrFail();
$this->authorize('view', $actionlog->item);
// PHP doesn't let you handle file not found errors well with
// file_get_contents, so we set the error reporting for just this class
error_reporting(0);
@@ -27,7 +31,6 @@ class ActionlogController extends Controller
return redirect()->away(Storage::disk($disk)->temporaryUrl($file, now()->addMinutes(5)));
default:
$this->authorize('view', Asset::class);
$file = config('app.private_uploads').'/signatures/'.$filename;
$filetype = Helper::checkUploadIsImage($file);
@@ -44,6 +47,7 @@ class ActionlogController extends Controller
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
{
$filename = basename((string) $filename);
if ($actionlog = Actionlog::where('filename', $filename)->with('user')->with('target')->firstOrFail()) {
@@ -4,24 +4,28 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAccessoryRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Http\Transformers\AccessoriesTransformer;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Company;
use App\Models\Setting;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class AccessoriesController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Display a listing of the resource.
@@ -69,20 +73,9 @@ class AccessoriesController extends Controller
->with('category', 'company', 'manufacturer', 'checkouts', 'location', 'supplier', 'adminuser')
->withCount('checkouts as checkouts_count');
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$accessories->ByFilter($filter);
} elseif ($request->filled('search')) {
$accessories->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$accessories->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('company_id')) {
@@ -94,27 +87,27 @@ class AccessoriesController extends Controller
}
if ($request->filled('category_id')) {
$accessories->where('category_id', '=', $request->input('category_id'));
$accessories->where('accessories.category_id', '=', $request->input('category_id'));
}
if ($request->filled('manufacturer_id')) {
$accessories->where('manufacturer_id', '=', $request->input('manufacturer_id'));
$accessories->where('accessories.manufacturer_id', '=', $request->input('manufacturer_id'));
}
if ($request->filled('supplier_id')) {
$accessories->where('supplier_id', '=', $request->input('supplier_id'));
$accessories->where('accessories.supplier_id', '=', $request->input('supplier_id'));
}
if ($request->filled('location_id')) {
$accessories->where('location_id', '=', $request->input('location_id'));
$accessories->where('accessories.location_id', '=', $request->input('location_id'));
}
if ($request->filled('notes')) {
$accessories->where('notes', '=', $request->input('notes'));
$accessories->where('accessories.notes', '=', $request->input('notes'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
$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';
@@ -165,6 +158,7 @@ class AccessoriesController extends Controller
{
$accessory = new Accessory;
$accessory->fill($request->all());
$accessory->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$accessory = $request->handleImages($accessory);
if ($accessory->save()) {
@@ -240,6 +234,10 @@ class AccessoriesController extends Controller
$total = $accessory_checkouts->count();
$accessory_checkouts = $accessory_checkouts->skip($offset)->take($limit)->get();
$accessory_checkouts->loadMorph('assignedTo', [
User::class => ['companies'],
]);
return (new AccessoriesTransformer)->transformCheckedoutAccessory($accessory_checkouts, $total);
}
@@ -258,6 +256,7 @@ class AccessoriesController extends Controller
$this->authorize('update', Accessory::class);
$accessory = Accessory::findOrFail($id);
$accessory->fill($request->all());
$accessory->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$accessory = $request->handleImages($accessory);
if ($accessory->save()) {
@@ -307,40 +306,49 @@ class AccessoriesController extends Controller
{
$this->authorize('checkout', $accessory);
$target = $this->determineCheckoutTarget();
$accessory->checkout_qty = $request->input('checkout_qty', 1);
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $target->companies()->where('companies.id', $accessory->company_id)->exists())) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Set this value to be able to pass the qty through to the event
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
$accessory->checkout_qty = $request->input('checkout_qty', 1);
$payload = null;
// Keep checkout rows and checkout log/event atomic to avoid ghost assignments.
DB::transaction(function () use ($accessory, $request, $target, &$payload): void {
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
}
// Set this value to be able to pass the qty through to the event.
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));
@@ -397,6 +405,7 @@ class AccessoriesController extends Controller
*/
public function selectlist(Request $request)
{
$this->authorize('view.selectlists');
$accessories = Accessory::select([
'accessories.id',
@@ -411,4 +420,16 @@ class AccessoriesController extends Controller
return (new SelectlistTransformer)->transformSelectlist($accessories);
}
public function history(Request $request, Accessory $accessory): JsonResponse|array
{
$this->authorize('history', $accessory);
$historyQuery = $accessory->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -6,6 +6,7 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAssetModelRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\AssetModelsTransformer;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -92,21 +93,9 @@ class AssetModelsController extends Controller
->withCount('assignedAssets as assets_assigned_count')
->withCount('archivedAssets as assets_archived_count');
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$assetmodels->ByFilter($filter);
} elseif ($request->filled('search')) {
$assetmodels->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$assetmodels->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->input('status') == 'deleted') {
@@ -144,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';
@@ -168,7 +158,6 @@ class AssetModelsController extends Controller
break;
}
$total = $assetmodels->count();
$assetmodels = $assetmodels->skip($offset)->take($limit)->get();
return (new AssetModelsTransformer)->transformAssetModels($assetmodels, $total);
@@ -350,4 +339,16 @@ class AssetModelsController extends Controller
return (new SelectlistTransformer)->transformSelectlist($assetmodels);
}
public function history(Request $request, AssetModel $model): JsonResponse|array
{
$this->authorize('history', $model);
$historyQuery = $model->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
+255 -84
View File
@@ -11,6 +11,7 @@ use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAssetRequest;
use App\Http\Requests\UpdateAssetRequest;
use App\Http\Traits\MigratesLegacyAssetLocations;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\AccessoryCheckout;
@@ -19,6 +20,7 @@ use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\ComponentAssignment;
use App\Models\CustomField;
use App\Models\License;
use App\Models\LicenseSeat;
@@ -37,6 +39,7 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* This class controls all actions related to assets for
@@ -126,9 +129,9 @@ class AssetsController extends Controller
'location',
'rtd_location',
'category',
'status_label',
'manufacturer',
'supplier',
'status',
'jobtitle',
'assigned_to',
'created_by',
@@ -141,17 +144,6 @@ class AssetsController extends Controller
$allowed_columns[] = $field->db_column_name();
}
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
$assets = Asset::select('assets.*')
// ->addSelect([
// 'first_checkout_at' => Actionlog::query()
@@ -165,7 +157,7 @@ class AssetsController extends Controller
->with(
'model',
'location',
'assetstatus',
'status',
'company',
'defaultLoc',
'assignedTo',
@@ -183,21 +175,9 @@ class AssetsController extends Controller
$assets->InModelList($non_deprecable_models->toArray());
}
// These are used by the API to query against specific ID numbers.
// They are also used by the individual searches on detail pages like
// locations, etc.
// Search custom fields by column name
foreach ($all_custom_fields as $field) {
if ($request->filled($field->db_column_name()) && $field->db_column_name()) {
$assets->where('assets.'.$field->db_column_name(), '=', $request->input($field->db_column_name()));
}
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$assets->ByFilter($filter);
} elseif ($request->filled('search')) {
$assets->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$assets->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
/**
@@ -240,10 +220,18 @@ class AssetsController extends Controller
// This is used by the sidenav, mostly
// We switched from using query scopes here because of a Laravel bug
// related to fulltext searches on complex queries.
// I am sad. :(
switch ($request->input('status')) {
// This bit here accounts for folks actually using the formerly-known-as status like we previously used in the sidenav
// to return a list of all assets with the status *type* of Deployed, etc. The inuput field used to be "status" (which was consistent
// with the relation rename, but it broke the sidebar. This should handle both use cases in the event that someone didn't update
// their API integration code
$status_type_key = null;
if ($request->filled('status_type')) {
$status_type_key = $request->input('status_type');
} elseif ($request->filled('status')) {
$status_type_key = $request->input('status');
}
switch ($status_type_key) {
case 'Deleted':
$assets->onlyTrashed();
break;
@@ -383,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.
@@ -415,7 +409,7 @@ class AssetsController extends Controller
case 'rtd_location':
$assets->OrderRtdLocation($order);
break;
case 'status_label':
case 'status':
$assets->OrderStatus($order);
break;
case 'supplier':
@@ -487,7 +481,7 @@ class AssetsController extends Controller
public function showByTag(Request $request, $tag): JsonResponse|array
{
$this->authorize('index', Asset::class);
$assets = Asset::where('asset_tag', $tag)->with('assetstatus')->with('assignedTo');
$assets = Asset::where('asset_tag', $tag)->with('status')->with('assignedTo');
// Check if they've passed ?deleted=true
if ($request->input('deleted', 'false') == 'true') {
@@ -527,7 +521,7 @@ class AssetsController extends Controller
{
$this->authorize('index', Asset::class);
$assets = Asset::where('serial', $serial)->with([
'assetstatus',
'status',
'assignedTo',
'company',
'defaultLoc',
@@ -571,7 +565,7 @@ class AssetsController extends Controller
*/
public function show(Request $request, $id): JsonResponse|array
{
if ($asset = Asset::with('assetstatus')
if ($asset = Asset::with('status')
->with('assignedTo')->withTrashed()
->withCount('checkins as checkins_count', 'checkouts as checkouts_count', 'userRequests as user_requests_count')->find($id)
) {
@@ -602,6 +596,7 @@ class AssetsController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$assets = Asset::select([
'assets.id',
@@ -611,14 +606,26 @@ class AssetsController extends Controller
'assets.assigned_to',
'assets.assigned_type',
'assets.status_id',
])->with('model', 'assetstatus', 'assignedTo')
])->with('model', 'status', 'assignedTo')
->NotArchived();
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$assets->where('assets.company_id', $request->input('companyId'));
// When FMCS is enabled, automatically scope to companies the acting user belongs to.
// scopeCompanyables is a no-op for superusers and when FMCS is disabled.
$assets = Company::scopeCompanyables($assets);
// Allow further narrowing to a specific company passed via data-company-id on the select.
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
if (! empty($companyIds)) {
$assets->whereIn('assets.company_id', $companyIds);
}
}
if ($request->filled('assetStatusType') && $request->input('assetStatusType') === 'RTD') {
if ($request->filled('excludeId')) {
$assets->where('assets.id', '!=', (int) $request->input('excludeId'));
}
if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
$assets = $assets->RTD();
}
@@ -639,8 +646,8 @@ class AssetsController extends Controller
$asset->use_text .= ' → '.$asset->assigned->display_name;
}
if ($asset->assetstatus->getStatuslabelType() == 'pending') {
$asset->use_text .= '('.$asset->assetstatus->getStatuslabelType().')';
if ($asset->status->getStatuslabelType() == 'pending') {
$asset->use_text .= '('.$asset->status->getStatuslabelType().')';
}
$asset->use_image = ($asset->getImageUrl()) ? $asset->getImageUrl() : null;
@@ -718,18 +725,35 @@ class AssetsController extends Controller
}
}
if ($asset->save()) {
if ($request->input('assigned_user')) {
$target = User::find(request('assigned_user'));
} elseif ($request->input('assigned_asset')) {
$target = Asset::find(request('assigned_asset'));
} elseif ($request->input('assigned_location')) {
$target = Location::find(request('assigned_location'));
$target = $this->resolveCheckoutTargetForAssetMutation($request);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
if (isset($target)) {
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
$stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if ($requestedCheckout) {
// Keep create + optional checkout side effects atomic.
return $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
return true;
});
if ($stored) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -804,25 +828,54 @@ class AssetsController extends Controller
}
}
}
if ($asset->save()) {
if (($request->filled('assigned_user')) && ($target = User::find($request->input('assigned_user')))) {
$location = $target->location_id;
} elseif (($request->filled('assigned_asset')) && ($target = Asset::find($request->input('assigned_asset')))) {
$location = $target->location_id;
$target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
} elseif (($request->filled('assigned_location')) && ($target = Location::find($request->input('assigned_location')))) {
$location = $target->id;
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
}
$updated = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if (isset($target)) {
if ($requestedCheckout) {
// Using `->has` preserves the asset name if the name parameter was not included in request.
$asset_name = request()->has('name') ? request('name') : $asset->name;
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location);
$location = null;
if ($request->filled('assigned_user')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_asset')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_location')) {
$location = $target->id;
}
// Keep update + optional checkout side effects atomic.
if (! $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location)) {
return false;
}
if ($request->filled('assigned_asset')) {
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
}
}
return true;
});
if ($updated) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -841,6 +894,32 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()), 200);
}
private function resolveCheckoutTargetForAssetMutation(Request $request, ?int $assetId = null): User|Asset|Location|null
{
if ($request->filled('assigned_user')) {
return User::withoutGlobalScopes()->find($request->input('assigned_user'));
}
if ($request->filled('assigned_asset')) {
return Asset::withoutGlobalScopes()->where('id', '!=', $assetId)->find($request->input('assigned_asset'));
}
if ($request->filled('assigned_location')) {
return Location::withoutGlobalScopes()->find($request->input('assigned_location'));
}
return null;
}
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
{
if (! $asset->canCheckoutTo($target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
return null;
}
/**
* Delete a given asset (mark as deleted).
*
@@ -917,6 +996,7 @@ class AssetsController extends Controller
*/
public function checkoutByTag(AssetCheckoutRequest $request, $tag): JsonResponse
{
// Use the same hardened checkout path as ID-based checkout.
if ($asset = Asset::where('asset_tag', $tag)->first()) {
return $this->checkout($request, $asset->id);
}
@@ -952,19 +1032,22 @@ class AssetsController extends Controller
// This item is checked out to a location
if (request('checkout_to_type') == 'location') {
$target = Location::find(request('assigned_location'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Location::withoutGlobalScopes()->find(request('assigned_location'));
$asset->location_id = ($target) ? $target->id : '';
$error_payload['target_id'] = $request->input('assigned_location');
$error_payload['target_type'] = 'location';
} elseif (request('checkout_to_type') == 'asset') {
$target = Asset::where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Override with the asset's location_id if it has one
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_asset');
$error_payload['target_type'] = 'asset';
} elseif (request('checkout_to_type') == 'user') {
// Fetch the target and set the asset's new location_id
$target = User::find(request('assigned_user'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = User::withoutGlobalScopes()->find(request('assigned_user'));
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_user');
$error_payload['target_type'] = 'user';
@@ -974,10 +1057,20 @@ class AssetsController extends Controller
$asset->status_id = $request->input('status_id');
}
// Preserve existing requestable state unless API caller explicitly includes the field.
if ($request->has('requestable')) {
$asset->requestable = $request->boolean('requestable');
}
if (! isset($target)) {
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
}
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
if ($mismatch = $this->checkoutCompanyMismatchResponse($asset, $target)) {
return $mismatch;
}
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
$expected_checkin = request('expected_checkin', null);
$note = request('note', null);
@@ -992,7 +1085,12 @@ class AssetsController extends Controller
// $asset->location_id = $target->rtd_location_id;
// }
if ($asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id)) {
// Keep checkout mutation + checkout logging/event side effects atomic.
$wasCheckedOut = DB::transaction(function () use ($asset, $target, $checkout_at, $expected_checkin, $note, $asset_name): bool {
return $asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id);
});
if ($wasCheckedOut) {
return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
}
@@ -1028,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');
}
@@ -1074,6 +1174,12 @@ class AssetsController extends Controller
});
if ($asset->save()) {
// Update the location of any child assets
Asset::where('assigned_type', Asset::class)
->where('assigned_to', $asset->id)
->update(['location_id' => $asset->location_id]);
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));
return response()->json(Helper::formatStandardApiResponse('success', [
@@ -1124,13 +1230,29 @@ class AssetsController extends Controller
$this->authorize('audit', Asset::class);
$settings = Setting::getSettings();
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
// Allow the asset tag to be passed in the payload (legacy method)
if ($request->filled('asset_tag')) {
$dt = null;
if (! is_null($settings->audit_interval)) {
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
}
$audit_by_field = $request->input('audit_by_field', 'asset_tag');
$audit_key = $request->input('audit_key', null);
// If they have selected to scan by serial, use that
if (($settings->unique_serial == '1') && ($audit_by_field == 'serial') && ($audit_key)) {
$asset = Asset::where('serial', '=', trim($audit_key))->first();
// If they have selected by asset tag, use that
} elseif (($audit_by_field == 'asset_tag') && ($audit_key)) {
$asset = Asset::where('asset_tag', '=', trim($audit_key))->first();
// Allow the asset tag to be passed in the payload (legacy method)
} elseif ($request->filled('asset_tag')) {
$asset = Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
}
// If none of the above were selected, fall back to the route-model-binding
if ($asset) {
$originalValues = $asset->getRawOriginal();
@@ -1149,13 +1271,19 @@ 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,
'asset_tag' => $asset->asset_tag,
'note' => e($request->input('note')),
'status_label' => e($asset->assetstatus?->display_name),
'status_type' => $asset->assetstatus?->getStatuslabelType(),
'asset_tag' => e($asset->asset_tag),
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
'note' => $request->filled('note') ? e($request->input('note')) : null,
'status_label' => e($asset->status?->display_name),
'status_type' => $asset->status?->getStatuslabelType(),
'next_audit_date' => Helper::getFormattedDateObject($asset->next_audit_date),
];
@@ -1194,7 +1322,7 @@ class AssetsController extends Controller
// Validate the rest of the data before we turn off the event dispatcher
if ($asset->isInvalid()) {
return response()->json(Helper::formatStandardApiResponse('error', ['asset_tag' => $asset->asset_tag], $asset->getErrors()));
return response()->json(Helper::formatStandardApiResponse('error', $payload, $asset->getErrors()));
}
/**
@@ -1227,8 +1355,13 @@ class AssetsController extends Controller
}
$fail_payload = [
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
];
// No matching asset for the asset tag that was passed.
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
return response()->json(Helper::formatStandardApiResponse('error', $fail_payload, trans('admin/hardware/message.does_not_exist')), 200);
}
@@ -1262,7 +1395,7 @@ class AssetsController extends Controller
$assets = Asset::select('assets.*')
->with(
'location',
'assetstatus',
'status',
'assetlog',
'company',
'assignedTo',
@@ -1337,7 +1470,6 @@ class AssetsController extends Controller
public function assignedAccessories(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('view', Asset::class);
$this->authorize('view', $asset);
$accessory_checkouts = AccessoryCheckout::AssetsAssigned()
->where('assigned_to', $asset->id)
@@ -1353,14 +1485,41 @@ class AssetsController extends Controller
return (new AssetsTransformer)->transformCheckedoutAccessories($accessory_checkouts, $total);
}
public function assignedComponents(Asset $asset): JsonResponse|array
public function assignedComponents(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('view', $asset);
$asset->loadCount('components');
$total = $asset->components_count;
$components = $asset->load(['components' => fn ($query) => $query->applyOffsetAndLimit($total)])->components;
return (new AssetsTransformer)->transformCheckedoutComponents($components, $total);
$allowed_columns = [
'created_at',
'assigned_qty',
'note',
];
$component_checkouts = ComponentAssignment::where('asset_id', $asset->id)->with('adminuser')->with('component');
$sort_override = $request->input('sort');
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at';
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
switch ($sort_override) {
case 'created_by':
$component_checkouts = $component_checkouts->OrderByCreatedByName($order);
break;
case 'name':
$component_checkouts = $component_checkouts->OrderByComponentName($order);
break;
default:
$component_checkouts = $component_checkouts->orderBy($column_sort, $order);
break;
}
$offset = ($request->input('offset') > $component_checkouts->count()) ? $component_checkouts->count() : app('api_offset_value');
$total = $component_checkouts->count();
$limit = app('api_limit_value');
$component_checkouts = $component_checkouts->skip($offset)->take($limit)->get();
return (new AssetsTransformer)->transformCheckedoutComponents($component_checkouts, $total);
}
/**
@@ -1402,7 +1561,7 @@ class AssetsController extends Controller
$label = new Label;
if (! $label) {
throw new \Exception('Label object could not be created');
throw new \Exception(trans('admin/labels/message.label_not_created'));
}
// Configure label with assets and settings
@@ -1423,7 +1582,7 @@ class AssetsController extends Controller
// Verify PDF was generated successfully
if (empty($pdf_content)) {
throw new \Exception('PDF content is empty');
throw new \Exception(trans('admin/labels/message.use_new_label_engine_for_api'));
}
$encoded_content = base64_encode($pdf_content);
@@ -1447,4 +1606,16 @@ class AssetsController extends Controller
], $e->getMessage()), 500);
}
}
public function history(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('history', $asset);
$historyQuery = $asset->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -6,6 +6,7 @@ use App\Actions\Categories\DestroyCategoryAction;
use App\Exceptions\ItemStillHasChildren;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\CategoriesTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -26,62 +27,50 @@ class CategoriesController extends Controller
*
* @return Response
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('view', Category::class);
$allowed_columns = [
'id',
'name',
'category_type',
'category_type',
'use_default_eula',
'eula_text',
'require_acceptance',
'checkin_email',
'assets_count',
'accessories_count',
'consumables_count',
'assets_count',
'category_type',
'checkin_email',
'components_count',
'licenses_count',
'consumables_count',
'created_at',
'updated_at',
'eula_text',
'id',
'image',
'tag_color',
'licenses_count',
'name',
'notes',
'require_acceptance',
'tag_color',
'updated_at',
'use_default_eula',
];
$categories = Category::select([
'id',
'created_by',
'created_at',
'updated_at',
'name', 'category_type',
'use_default_eula',
'eula_text',
'require_acceptance',
'category_type',
'checkin_email',
'created_at',
'created_by',
'eula_text',
'id',
'image',
'tag_color',
'name',
'notes',
'require_acceptance',
'tag_color',
'updated_at',
'use_default_eula',
])
->with('adminuser')
->withCount('accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count', 'models as models_count');
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$categories->ByFilter($filter);
} elseif ($request->filled('search')) {
$categories->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$categories->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
/*
@@ -140,6 +129,11 @@ class CategoriesController extends Controller
case 'created_by':
$categories = $categories->OrderByCreatedBy($order);
break;
// This is annoying, since it's not a real relationship, which is what we usually use these switches for, but
// we call the field has_eula, not eula_text, so there won't be a matching field
case 'has_eula':
$categories = $categories->orderBy('eula_text', $order);
break;
default:
$categories = $categories->orderBy($column_sort, $order);
break;
@@ -4,10 +4,12 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
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;
@@ -21,7 +23,7 @@ class CompaniesController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Company::class);
@@ -49,8 +51,9 @@ class CompaniesController extends Controller
->with('adminuser')
->withCount('licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'users as users_count');
if ($request->filled('search')) {
$companies->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$companies->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -204,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').'%');
}
@@ -6,9 +6,12 @@ use App\Events\CheckoutableCheckedIn;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\ComponentsTransformer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Component;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
@@ -72,14 +75,13 @@ class ComponentsController extends Controller
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$components->ByFilter($filter);
} elseif ($request->filled('search')) {
$components->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$components->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
$components->where('name', '=', $request->input('name'));
$components->where('components.name', '=', $request->input('name'));
}
if ($request->filled('company_id')) {
@@ -91,27 +93,27 @@ class ComponentsController extends Controller
}
if ($request->filled('category_id')) {
$components->where('category_id', '=', $request->input('category_id'));
$components->where('components.category_id', '=', $request->input('category_id'));
}
if ($request->filled('supplier_id')) {
$components->where('supplier_id', '=', $request->input('supplier_id'));
$components->where('components.supplier_id', '=', $request->input('supplier_id'));
}
if ($request->filled('manufacturer_id')) {
$components->where('manufacturer_id', '=', $request->input('manufacturer_id'));
$components->where('components.manufacturer_id', '=', $request->input('manufacturer_id'));
}
if ($request->filled('model_number')) {
$components->where('model_number', '=', $request->input('model_number'));
$components->where('components.model_number', '=', $request->input('model_number'));
}
if ($request->filled('location_id')) {
$components->where('location_id', '=', $request->input('location_id'));
$components->where('components.location_id', '=', $request->input('location_id'));
}
if ($request->filled('notes')) {
$components->where('notes', '=', $request->input('notes'));
$components->where('components.notes', '=', $request->input('notes'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -165,6 +167,7 @@ class ComponentsController extends Controller
$this->authorize('create', Component::class);
$component = new Component;
$component->fill($request->all());
$component->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$component = $request->handleImages($component);
if ($component->save()) {
@@ -205,6 +208,7 @@ class ComponentsController extends Controller
$this->authorize('update', Component::class);
$component = Component::findOrFail($id);
$component->fill($request->all());
$component->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$component = $request->handleImages($component);
if ($component->save()) {
@@ -247,12 +251,10 @@ class ComponentsController extends Controller
*
* @param int $id
*/
public function getAssets(Request $request, $id): array
public function getAssets(Component $component, Request $request): array
{
$this->authorize('view', Asset::class);
$component = Component::findOrFail($id);
$offset = request('offset', 0);
$limit = $request->input('limit', 50);
@@ -271,7 +273,6 @@ class ComponentsController extends Controller
$total = $assets->count();
} else {
$assets = $component->assets();
$total = $assets->count();
$assets = $assets->skip($offset)->take($limit)->get();
}
@@ -314,20 +315,33 @@ class ComponentsController extends Controller
}
if ($component->numRemaining() >= $request->input('assigned_qty')) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company records and lead to partial writes.
$asset = Asset::withoutGlobalScopes()->find($request->input('assigned_to'));
$asset = Asset::find($request->input('assigned_to'));
$component->assigned_to = $request->input('assigned_to');
if (! $asset) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($component->company_id !== $asset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
// Keep pivot + action log in one transaction so checkout is all-or-nothing.
DB::transaction(function () use ($component, $request, $asset): void {
$component->assigned_to = $request->input('assigned_to');
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkout.success')));
}
@@ -387,4 +401,16 @@ class ComponentsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, 'No matching checkouts for that component join record'));
}
public function history(Request $request, Component $component): JsonResponse|array
{
$this->authorize('history', $component);
$historyQuery = $component->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -5,15 +5,19 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreConsumableRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\ConsumablesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ConsumablesController extends Controller
{
@@ -24,7 +28,7 @@ class ConsumablesController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('index', Consumable::class);
@@ -59,25 +63,13 @@ class ConsumablesController extends Controller
'manufacturer',
];
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$consumables->ByFilter($filter);
} elseif ($request->filled('search')) {
$consumables->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$consumables->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
$consumables->where('name', '=', $request->input('name'));
$consumables->where('consumables.name', '=', $request->input('name'));
}
if ($request->filled('company_id')) {
@@ -89,27 +81,27 @@ class ConsumablesController extends Controller
}
if ($request->filled('category_id')) {
$consumables->where('category_id', '=', $request->input('category_id'));
$consumables->where('consumables.category_id', '=', $request->input('category_id'));
}
if ($request->filled('model_number')) {
$consumables->where('model_number', '=', $request->input('model_number'));
$consumables->where('consumables.model_number', '=', $request->input('model_number'));
}
if ($request->filled('manufacturer_id')) {
$consumables->where('manufacturer_id', '=', $request->input('manufacturer_id'));
$consumables->where('consumables.manufacturer_id', '=', $request->input('manufacturer_id'));
}
if ($request->filled('supplier_id')) {
$consumables->where('supplier_id', '=', $request->input('supplier_id'));
$consumables->where('consumables.supplier_id', '=', $request->input('supplier_id'));
}
if ($request->filled('location_id')) {
$consumables->where('location_id', '=', $request->input('location_id'));
$consumables->where('consumables.location_id', '=', $request->input('location_id'));
}
if ($request->filled('notes')) {
$consumables->where('notes', '=', $request->input('notes'));
$consumables->where('consumables.notes', '=', $request->input('notes'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -165,6 +157,7 @@ class ConsumablesController extends Controller
$this->authorize('create', Consumable::class);
$consumable = new Consumable;
$consumable->fill($request->all());
$consumable->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$consumable = $request->handleImages($consumable);
if ($consumable->save()) {
@@ -204,6 +197,7 @@ class ConsumablesController extends Controller
$this->authorize('update', Consumable::class);
$consumable = Consumable::findOrFail($id);
$consumable->fill($request->all());
$consumable->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$consumable = $request->handleImages($consumable);
if ($consumable->save()) {
@@ -314,34 +308,42 @@ class ConsumablesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining()])));
}
// Check if the user exists - @TODO: this should probably be handled via validation, not here??
if (! $user = User::find($request->input('assigned_to'))) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company users and make failures ambiguous.
if (! $user = User::withoutGlobalScopes()->find($request->input('assigned_to'))) {
// Return error message
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $user->companies()->where('companies.id', $consumable->company_id)->exists())) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Update the consumable data
$consumable->assigned_to = $request->input('assigned_to');
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
// Keep pivot writes and checkout log/event atomic to avoid partial checkout state.
DB::transaction(function () use ($consumable, $request, $user): void {
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success')));
@@ -354,6 +356,8 @@ class ConsumablesController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$consumables = Consumable::select([
'consumables.id',
'consumables.name',
@@ -367,4 +371,16 @@ class ConsumablesController extends Controller
return (new SelectlistTransformer)->transformSelectlist($consumables);
}
public function history(Request $request, Consumable $consumable): JsonResponse|array
{
$this->authorize('history', $consumable);
$historyQuery = $consumable->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -4,10 +4,12 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreDepartmentRequest;
use App\Http\Transformers\DepartmentsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\Department;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -22,7 +24,7 @@ class DepartmentsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Department::class);
$allowed_columns = ['id', 'name', 'image', 'users_count', 'notes', 'tag_color'];
@@ -43,28 +45,29 @@ class DepartmentsController extends Controller
'departments.notes',
])->with('location')->with('manager')->with('company')->withCount('users as users_count');
if ($request->filled('search')) {
$departments = $departments->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$departments->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
$departments->where('name', '=', $request->input('name'));
$departments->where('departments.name', '=', $request->input('name'));
}
if ($request->filled('company_id')) {
$departments->where('company_id', '=', $request->input('company_id'));
$departments->where('departments.company_id', '=', $request->input('company_id'));
}
if ($request->filled('manager_id')) {
$departments->where('manager_id', '=', $request->input('manager_id'));
$departments->where('departments.manager_id', '=', $request->input('manager_id'));
}
if ($request->filled('location_id')) {
$departments->where('location_id', '=', $request->input('location_id'));
$departments->where('departments.location_id', '=', $request->input('location_id'));
}
if ($request->filled('tag_color')) {
$departments->where('tag_color', '=', $request->input('departments.tag_color'));
$departments->where('departments.tag_color', '=', $request->input('tag_color'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -109,6 +112,7 @@ class DepartmentsController extends Controller
{
$department = new Department;
$department->fill($request->validated());
$department->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$department = $request->handleImages($department);
$department->created_by = auth()->id();
@@ -153,6 +157,7 @@ class DepartmentsController extends Controller
$this->authorize('update', Department::class);
$department = Department::findOrFail($id);
$department->fill($request->all());
$department->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$department = $request->handleImages($department);
if ($department->save()) {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\DepreciationsTransformer;
use App\Models\Depreciation;
use Illuminate\Http\JsonResponse;
@@ -18,7 +19,7 @@ class DepreciationsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Depreciation::class);
$allowed_columns = [
@@ -33,14 +34,15 @@ class DepreciationsController extends Controller
'licenses_count',
];
$depreciations = Depreciation::select('id', 'name', 'months', 'depreciation_min', 'depreciation_type', 'created_at', 'updated_at', 'created_by')
$depreciations = Depreciation::select(['id', 'name', 'months', 'depreciation_min', 'depreciation_type', 'created_at', 'updated_at', 'created_by'])
->with('adminuser')
->withCount('assets as assets_count')
->withCount('models as models_count')
->withCount('licenses as licenses_count');
if ($request->filled('search')) {
$depreciations = $depreciations->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$depreciations->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
+27 -12
View File
@@ -2,8 +2,10 @@
namespace App\Http\Controllers\Api;
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\GroupsTransformer;
use App\Models\Group;
use Illuminate\Http\JsonResponse;
@@ -18,7 +20,7 @@ class GroupsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('superadmin');
@@ -26,8 +28,9 @@ class GroupsController extends Controller
$groups = Group::select(['id', 'name', 'permissions', 'notes', 'created_at', 'updated_at', 'created_by'])->with('adminuser')->withCount('users as users_count');
if ($request->filled('search')) {
$groups = $groups->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$groups->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -75,14 +78,17 @@ class GroupsController extends Controller
{
$this->authorize('superadmin');
$group = new Group;
// Get all the available permissions
$permissions = json_encode(config('permissions'));
$groupPermissions = Helper::selectedPermissionsArray($permissions, $permissions);
$defaultPermissions = Helper::selectedPermissionsArray(config('permissions'), config('permissions'));
$group->name = $request->input('name');
$requestedPermissions = $request->has('permissions')
? NormalizePermissionsPayloadAction::run($request->input('permissions'))
: $defaultPermissions;
$group->fill($request->only(['name', 'notes']));
$group->created_by = auth()->id();
$group->notes = $request->input('notes');
$group->permissions = json_encode($request->input('permissions', $groupPermissions));
$group->permissions = json_encode(
Helper::selectedPermissionsArray(config('permissions'), $requestedPermissions)
);
if ($group->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new GroupsTransformer)->transformGroup($group), trans('admin/groups/message.success.create')));
@@ -122,9 +128,18 @@ class GroupsController extends Controller
$this->authorize('superadmin');
$group = Group::findOrFail($id);
$group->name = $request->input('name');
$group->notes = $request->input('notes');
$group->permissions = $request->input('permissions'); // Todo - some JSON validation stuff here
// Fill only the keys present in the request, so PATCH skips absent fields naturally.
$group->fill($request->only(['name', 'notes']));
// Preserve existing permissions when omitted from PATCH/PUT payload.
if ($request->has('permissions')) {
$group->permissions = json_encode(
Helper::selectedPermissionsArray(
config('permissions'),
NormalizePermissionsPayloadAction::run($request->input('permissions'))
)
);
}
if ($group->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new GroupsTransformer)->transformGroup($group), trans('admin/groups/message.success.update')));
@@ -8,9 +8,11 @@ use App\Http\Transformers\LicenseSeatsTransformer;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class LicenseSeatsController extends Controller
{
@@ -25,7 +27,7 @@ class LicenseSeatsController extends Controller
if ($license = License::find($licenseId)) {
$this->authorize('view', $license);
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department', 'user.company', 'asset.company')
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department', 'user.companies', 'asset.company')
->where('license_seats.license_id', $licenseId);
if ($request->input('status') == 'available') {
@@ -106,7 +108,8 @@ class LicenseSeatsController extends Controller
'prohibits:asset_id',
// must be a valid user or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! User::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! User::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected assigned_to is invalid.');
}
},
@@ -118,7 +121,8 @@ class LicenseSeatsController extends Controller
'prohibits:assigned_to',
// must be a valid asset or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! Asset::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! Asset::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected asset_id is invalid.');
}
},
@@ -128,77 +132,141 @@ class LicenseSeatsController extends Controller
$this->authorize('checkout', License::class);
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])->find($seatId);
$errorResponse = null;
$updatedSeat = null;
if (! $licenseSeat) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
}
// Fetch the seat with a pessimistic lock inside a transaction so concurrent requests
// on the same seat serialise rather than racing to overwrite each other's assignment.
DB::transaction(function () use ($request, $licenseId, $seatId, $validated, &$errorResponse, &$updatedSeat): void {
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])
->lockForUpdate()
->find($seatId);
$license = $licenseSeat->license;
if (! $license || $license->id != intval($licenseId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
}
if (! $licenseSeat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
// attempt to update the license seat
$licenseSeat->fill($validated);
// check if this update is a checkin operation
// 1. are relevant fields touched at all?
$assignmentTouched = $licenseSeat->isDirty('assigned_to') || $licenseSeat->isDirty('asset_id');
$anythingTouched = $licenseSeat->isDirty();
if (! $anythingTouched) {
return response()->json(
Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))
);
}
if ($assignmentTouched && $licenseSeat->unreassignable_seat) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
}
// 2. are they cleared? if yes then this is a checkin operation
$is_checkin = ($assignmentTouched && $licenseSeat->assigned_to === null && $licenseSeat->asset_id === null);
$target = null;
// the logging functions expect only one "target". if both asset and user are present in the request,
// we simply let assets take precedence over users...
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : User::find($licenseSeat->assigned_to);
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : Asset::find($licenseSeat->asset_id);
}
if ($assignmentTouched && is_null($target)) {
// if both asset_id and assigned_to are null then we are "checking-in"
// a related model that does not exist (possible purged or bad data).
if (! is_null($request->input('asset_id')) || ! is_null($request->input('assigned_to'))) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
$license = $licenseSeat->license;
if (! $license || $license->id != intval($licenseId)) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
return;
}
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $targetUser->companies()->where('companies.id', $license->company_id)->exists())) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
}
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
if (! $targetAsset) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
$licenseSeat->fill($validated);
$assignmentTouched = $licenseSeat->isDirty('assigned_to') || $licenseSeat->isDirty('asset_id');
$anythingTouched = $licenseSeat->isDirty();
if (! $anythingTouched) {
$updatedSeat = $licenseSeat;
return;
}
if ($assignmentTouched && $licenseSeat->unreassignable_seat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
return;
}
// Are the assignment fields cleared? If yes, this is a checkin operation.
$is_checkin = ($assignmentTouched && $licenseSeat->assigned_to === null && $licenseSeat->asset_id === null);
// The logging functions expect only one "target"; assets take precedence over users.
$target = null;
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
// Both fields are null but one was provided — the related model is purged or bad data.
if (! is_null($request->input('asset_id')) || ! is_null($request->input('assigned_to'))) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
}
if (! $licenseSeat->save()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
}
if ($licenseSeat->save()) {
if ($assignmentTouched) {
if ($is_checkin) {
if (! $licenseSeat->license->reassignable) {
$licenseSeat->unreassignable_seat = true;
$licenseSeat->save();
if (! $licenseSeat->save()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
}
// todo: skip if target is null?
$licenseSeat->logCheckin($target, $licenseSeat->notes);
} else {
// in this case, relevant fields are touched but it's not a checkin operation. so it must be a checkout operation.
$licenseSeat->logCheckout($request->input('notes'), $target);
}
}
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
$updatedSeat = $licenseSeat;
});
if ($errorResponse) {
return $errorResponse;
}
return Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors());
if ($updatedSeat) {
return response()->json(Helper::formatStandardApiResponse('success', $updatedSeat, trans('admin/licenses/message.update.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'An unexpected error occurred'), 500);
}
}
+192 -5
View File
@@ -2,12 +2,21 @@
namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedIn;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\LicenseSeatsTransformer;
use App\Http\Transformers\LicensesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -21,11 +30,11 @@ class LicensesController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', License::class);
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser')->withCount('freeSeats as free_seats_count');
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser', 'licenseSeatsRelation', 'assignedCount')->withCount('freeSeats as free_seats_count');
$settings = Setting::getSettings();
if ($request->input('status') == 'inactive') {
@@ -96,8 +105,9 @@ class LicensesController extends Controller
$licenses->whereNull('expiration_date');
}
if ($request->filled('search')) {
$licenses = $licenses->TextSearch($request->input('search'));
// This invokes the Searchable model trait and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$licenses->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->input('deleted') == 'true') {
@@ -176,6 +186,7 @@ class LicensesController extends Controller
$this->authorize('create', License::class);
$license = new License;
$license->fill($request->all());
$license->company_id = Company::getIdForCurrentUser($request->input('company_id'));
if ($license->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $license, trans('admin/licenses/message.create.success')));
@@ -216,6 +227,7 @@ class LicensesController extends Controller
$license = License::findOrFail($id);
$license->fill($request->all());
$license->company_id = Company::getIdForCurrentUser($request->input('company_id'));
if ($license->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $license, trans('admin/licenses/message.update.success')));
@@ -241,7 +253,7 @@ class LicensesController extends Controller
if ($license->assigned_seats_count == 0) {
// Delete the license and the associated license seats
DB::table('license_seats')
->where('id', $license->id)
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$licenseSeats = $license->licenseseats();
@@ -255,6 +267,167 @@ class LicensesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.assoc_users')));
}
/**
* Checkout a license seat to a user or asset.
*
* Accepts an optional `seat_id`; if omitted the next available free seat is used.
* `target_type` must be "user" or "asset". Supply `assigned_to` for users or
* `asset_id` for assets.
*
* This will eventually use the same form request the UI uses, but we need to update the field names first.
*
* @param int $licenseId
*/
public function checkout(Request $request, $licenseId): JsonResponse
{
$license = License::findOrFail($licenseId);
$this->authorize('checkout', $license);
$validated = $this->validate($request, [
'seat_id' => 'sometimes|integer|nullable',
'target_type' => 'required|in:user,asset',
'assigned_to' => 'required_if:target_type,user|integer|nullable',
'asset_id' => 'required_if:target_type,asset|integer|nullable',
'notes' => 'sometimes|string|nullable',
]);
if ($license->isInactive()) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.license_is_inactive')));
}
$errorResponse = null;
$updatedSeat = null;
$target = null;
DB::transaction(function () use ($license, $validated, &$errorResponse, &$updatedSeat, &$target): void {
$seatId = $validated['seat_id'] ?? null;
$licenseSeat = $seatId
? LicenseSeat::where('id', $seatId)->where('license_id', $license->id)->lockForUpdate()->first()
: $license->freeSeat(lock: true);
if (! $licenseSeat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.not_enough_seats')));
return;
}
if ($licenseSeat->unreassignable_seat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
return;
}
if ($validated['target_type'] === 'user') {
$target = User::withoutGlobalScopes()->whereNull('deleted_at')->find($validated['assigned_to'] ?? null);
if (! $target) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.user_does_not_exist')));
return;
}
if (Company::isFullMultipleCompanySupportEnabled() && ! $target->companies()->where('companies.id', $license->company_id)->exists()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
$licenseSeat->assigned_to = $target->id;
$licenseSeat->asset_id = null;
} else {
$target = Asset::withoutGlobalScopes()->whereNull('deleted_at')->find($validated['asset_id'] ?? null);
if (! $target) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.asset_does_not_exist')));
return;
}
if (Company::isFullMultipleCompanySupportEnabled() && $license->company_id && $license->company_id !== $target->company_id) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
$licenseSeat->asset_id = $target->id;
$licenseSeat->assigned_to = null;
if ($target->checkedOutToUser()) {
$licenseSeat->assigned_to = $target->assigned_to;
}
}
$licenseSeat->notes = $validated['notes'] ?? null;
$licenseSeat->created_by = auth()->id();
if (! $licenseSeat->save()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), $validated['notes'] ?? null));
$updatedSeat = $licenseSeat->load('license', 'user', 'asset');
});
if ($errorResponse) {
return $errorResponse;
}
if ($updatedSeat) {
return response()->json(Helper::formatStandardApiResponse('success', (new LicenseSeatsTransformer)->transformLicenseSeat($updatedSeat), trans('admin/licenses/message.checkout.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'An unexpected error occurred'), 500);
}
/**
* Checkin a license seat.
*
* `seat_id` is required to identify which seat to check back in.
*
* @param int $licenseId
*/
public function checkin(Request $request, $licenseId): JsonResponse
{
$license = License::findOrFail($licenseId);
$this->authorize('checkin', $license);
$validated = $this->validate($request, [
'seat_id' => 'required|integer',
'notes' => 'sometimes|string|nullable',
]);
$licenseSeat = LicenseSeat::where('id', $validated['seat_id'])
->where('license_id', $license->id)
->first();
if (! $licenseSeat) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.not_found')));
}
if (is_null($licenseSeat->assigned_to) && is_null($licenseSeat->asset_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkin.error')));
}
$target = $licenseSeat->user ?? $licenseSeat->asset;
$licenseSeat->assigned_to = null;
$licenseSeat->asset_id = null;
$licenseSeat->notes = $validated['notes'] ?? null;
if (! $license->reassignable) {
$licenseSeat->unreassignable_seat = true;
}
if (! $licenseSeat->save()) {
return response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
}
event(new CheckoutableCheckedIn($licenseSeat, $target, auth()->user(), $licenseSeat->notes));
return response()->json(Helper::formatStandardApiResponse('success', (new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat->load('license', 'user', 'asset')), trans('admin/licenses/message.checkin.success')));
}
/**
* Gets a paginated collection for the select2 menus
*
@@ -262,6 +435,8 @@ class LicensesController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$licenses = License::select([
'licenses.id',
'licenses.name',
@@ -275,4 +450,16 @@ class LicensesController extends Controller
return (new SelectlistTransformer)->transformSelectlist($licenses);
}
public function history(Request $request, License $license): JsonResponse|array
{
$this->authorize('history', $license);
$historyQuery = $license->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\LocationsTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -31,7 +33,7 @@ class LocationsController extends Controller
*
* @return Response
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Location::class);
$allowed_columns = [
@@ -65,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',
@@ -101,13 +114,16 @@ 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);
}
if ($request->filled('search')) {
$locations = $locations->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$locations->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -154,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';
@@ -177,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);
@@ -196,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'),
])));
}
}
@@ -224,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',
@@ -276,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();
@@ -308,7 +360,7 @@ class LocationsController extends Controller
{
$this->authorize('view', Asset::class);
$this->authorize('view', $location);
$assets = Asset::where('location_id', '=', $location->id)->with('model', 'model.category', 'assetstatus', 'location', 'company', 'defaultLoc');
$assets = Asset::where('location_id', '=', $location->id)->with('model', 'model.category', 'status', 'location', 'company', 'defaultLoc');
$assets = $assets->get();
return (new AssetsTransformer)->transformAssets($assets, $assets->count(), $request);
@@ -318,7 +370,7 @@ class LocationsController extends Controller
{
$this->authorize('view', Asset::class);
$this->authorize('view', $location);
$assets = Asset::where('assigned_to', '=', $location->id)->where('assigned_type', '=', Location::class)->with('model', 'model.category', 'assetstatus', 'location', 'company', 'defaultLoc');
$assets = Asset::where('assigned_to', '=', $location->id)->where('assigned_type', '=', Location::class)->with('model', 'model.category', 'status', 'location', 'company', 'defaultLoc');
$assets = $assets->get();
return (new AssetsTransformer)->transformAssets($assets, $assets->count(), $request);
@@ -419,11 +471,6 @@ class LocationsController extends Controller
'locations.tag_color',
]);
// Only scope locations if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$locations = Company::scopeCompanyables($locations);
}
$page = 1;
if ($request->filled('page')) {
$page = $request->input('page');
@@ -433,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 = [];
@@ -455,4 +506,16 @@ class LocationsController extends Controller
return (new SelectlistTransformer)->transformSelectlist($paginated_results);
}
public function history(Request $request, Location $location): JsonResponse|array
{
$this->authorize('history', $location);
$historyQuery = $location->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\MaintenanceTypesTransformer;
use App\Models\MaintenanceType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MaintenanceTypesController extends Controller
{
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', MaintenanceType::class);
$types = MaintenanceType::select(['id', 'name', 'created_at', 'updated_at', 'deleted_at']);
if ($request->input('deleted') == 'true') {
$types->onlyTrashed();
}
if ($request->filled('search')) {
$types->where('name', 'LIKE', '%'.$request->input('search').'%');
}
if ($request->filled('name')) {
$types->where('name', '=', $request->input('name'));
}
$offset = ($request->input('offset') > $types->count()) ? $types->count() : 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';
$total = $types->count();
$types = $types->orderBy($sort, $order)->skip($offset)->take($limit)->get();
return (new MaintenanceTypesTransformer)->transformMaintenanceTypes($types, $total);
}
public function show(MaintenanceType $maintenanceType): JsonResponse|array
{
$this->authorize('view', $maintenanceType);
return (new MaintenanceTypesTransformer)->transformMaintenanceType($maintenanceType);
}
public function store(Request $request): JsonResponse
{
$this->authorize('create', MaintenanceType::class);
$type = new MaintenanceType;
$type->name = $request->input('name');
$type->created_by = auth()->id();
if ($type->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenanceTypesTransformer)->transformMaintenanceType($type), trans('admin/maintenance_types/message.create.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $type->getErrors()));
}
public function update(Request $request, MaintenanceType $maintenanceType): JsonResponse
{
$this->authorize('update', $maintenanceType);
$maintenanceType->name = $request->input('name');
if ($maintenanceType->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenanceTypesTransformer)->transformMaintenanceType($maintenanceType), trans('admin/maintenance_types/message.update.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenanceType->getErrors()));
}
public function destroy(MaintenanceType $maintenanceType): JsonResponse
{
$this->authorize('delete', $maintenanceType);
$maintenanceType->delete();
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/maintenance_types/message.delete.success')));
}
}
@@ -2,13 +2,19 @@
namespace App\Http\Controllers\Api;
use App\Enums\ActionType;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\MaintenancesTransformer;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -31,15 +37,17 @@ class MaintenancesController extends Controller
*
* @since [v1.8]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Asset::class);
$maintenances = Maintenance::select('maintenances.*')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.assetstatus', 'adminuser', 'asset.assignedTo',);
->whereHas('asset')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo', 'maintenanceType', 'responsibleParty', 'completedByUser');
if ($request->filled('search')) {
$maintenances = $maintenances->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$maintenances->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('asset_id')) {
@@ -58,22 +66,53 @@ class MaintenancesController extends Controller
$maintenances->where('maintenances.url', '=', $request->input('url'));
}
if ($request->filled('asset_maintenance_type')) {
$maintenances->where('asset_maintenance_type', '=', $request->input('asset_maintenance_type'));
if ($request->filled('maintenance_type')) {
$maintenances->where('maintenance_type', '=', $request->input('maintenance_type'));
}
if ($request->filled('maintenance_type_id')) {
$maintenances->where('maintenance_type_id', '=', $request->input('maintenance_type_id'));
}
if ($request->filled('responsible_party_id')) {
$maintenances->where('responsible_party_id', '=', $request->input('responsible_party_id'));
}
if ($request->filled('completed')) {
if ($request->input('completed') === 'true') {
$maintenances->completed();
} else {
$maintenances->active();
}
}
if ($request->filled('upcoming_status')) {
$settings = Setting::getSettings();
switch ($request->input('upcoming_status')) {
case 'due':
$maintenances->dueForCompletion($settings);
break;
case 'overdue':
$maintenances->overdueForCompletion();
break;
case 'due-or-overdue':
$maintenances->dueOrOverdueForCompletion($settings);
break;
}
}
// Make sure the offset and limit are actually integers and do not exceed system limits
$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 = [
'id',
'name',
'asset_maintenance_time',
'asset_maintenance_type',
'cost',
'start_date',
'completion_date',
'completed_at',
'notes',
'asset_tag',
'asset_name',
@@ -85,6 +124,7 @@ class MaintenancesController extends Controller
'status_label',
'model',
'model_number',
'maintenance_type',
];
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
@@ -92,31 +132,37 @@ class MaintenancesController extends Controller
switch ($sort) {
case 'created_by':
$maintenances = $maintenances->OrderByCreatedBy($order);
$maintenances = $maintenances->orderByCreatedBy($order);
break;
case 'supplier':
$maintenances = $maintenances->OrderBySupplier($order);
$maintenances = $maintenances->orderBySupplier($order);
break;
case 'asset_tag':
$maintenances = $maintenances->OrderByTag($order);
$maintenances = $maintenances->orderByTag($order);
break;
case 'asset_name':
$maintenances = $maintenances->OrderByAssetName($order);
$maintenances = $maintenances->orderByAssetName($order);
break;
case 'model':
$maintenances = $maintenances->OrderByAssetModelName($order);
$maintenances = $maintenances->orderByAssetModelName($order);
break;
case 'model_number':
$maintenances = $maintenances->OrderByAssetModelNumber($order);
$maintenances = $maintenances->orderByAssetModelNumber($order);
break;
case 'serial':
$maintenances = $maintenances->OrderByAssetSerial($order);
$maintenances = $maintenances->orderByAssetSerial($order);
break;
case 'location':
$maintenances = $maintenances->OrderLocationName($order);
$maintenances = $maintenances->orderLocationName($order);
break;
case 'status_label':
$maintenances = $maintenances->OrderStatusName($order);
$maintenances = $maintenances->orderStatusName($order);
break;
case 'maintenance_type':
$maintenances = $maintenances->orderByMaintenanceType($order);
break;
case 'completed_at':
$maintenances = $maintenances->orderByCompletedAt($order);
break;
default:
$maintenances = $maintenances->orderBy($sort, $order);
@@ -129,7 +175,7 @@ class MaintenancesController extends Controller
if (request()->input('format') == 'flat') {
return (new MaintenancesTransformer)->transformMaintenancesFlat($maintenances, $total);
}
return (new MaintenancesTransformer)->transformMaintenances($maintenances, $total);
}
@@ -149,19 +195,60 @@ class MaintenancesController extends Controller
{
$this->authorize('update', Asset::class);
// create a new model instance
$maintenance = new Maintenance;
$maintenance->fill($request->all());
$maintenance->created_by = auth()->id();
$maintenance = $request->handleImages($maintenance);
// Was the asset maintenance created?
if ($maintenance->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.create.success')));
$isBulk = $request->has('asset_ids');
$assetIds = $isBulk
? array_values(array_filter((array) $request->input('asset_ids')))
: [$request->input('asset_id')];
$created = new EloquentCollection;
$errors = [];
foreach ($assetIds as $assetId) {
$asset = Asset::find($assetId);
if (! $asset) {
$errors[] = trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $assetId]);
continue;
}
if (! Company::isCurrentUserHasAccess($asset)) {
$errors[] = trans('general.action_permission_denied', ['item_type' => trans('general.asset'), 'id' => $assetId, 'action' => trans('general.create')]);
continue;
}
$maintenance = new Maintenance;
$maintenance->fill($request->except(['asset_id', 'asset_ids']));
$maintenance->asset_id = $assetId;
$maintenance->created_by = auth()->id();
$request->handleImages($maintenance);
if ($maintenance->save()) {
$created->push($maintenance->fresh());
} else {
$errors[] = $maintenance->getErrors();
}
}
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenance->getErrors()));
if ($isBulk) {
if ($created->isEmpty()) {
return response()->json(Helper::formatStandardApiResponse('error', null, count($errors) === 1 ? $errors[0] : $errors));
}
return response()->json(Helper::formatStandardApiResponse(
'success',
(new MaintenancesTransformer)->transformMaintenances($created, $created->count()),
trans('admin/maintenances/message.create.success')
));
}
// Single asset_id path — backward compatible response shape
if ($created->isNotEmpty()) {
return response()->json(Helper::formatStandardApiResponse('success', $created->first(), trans('admin/maintenances/message.create.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, ! empty($errors) ? $errors[0] : null));
}
/**
@@ -182,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')));
@@ -251,4 +354,91 @@ class MaintenancesController extends Controller
return (new MaintenancesTransformer)->transformMaintenance($maintenance);
}
public function complete(Request $request, Maintenance $maintenance): JsonResponse
{
$this->authorize('update', Asset::class);
if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $maintenance->id, 'action' => trans('admin/maintenances/form.mark_complete')])));
}
if ($maintenance->completed_at) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/maintenances/form.already_complete')));
}
$maintenance->completed_at = now();
$maintenance->completed_by = auth()->id();
$maintenance->asset_maintenance_time = (int) $maintenance->created_at->diffInDays(now(), true);
$maintenance->saveQuietly();
$logAction = new Actionlog;
$logAction->item_type = Maintenance::class;
$logAction->item_id = $maintenance->id;
$logAction->target_type = Asset::class;
$logAction->target_id = $maintenance->asset_id;
$logAction->created_by = auth()->id();
$logAction->note = $request->input('note');
$logAction->logaction(ActionType::MaintenanceComplete);
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenancesTransformer)->transformMaintenance($maintenance->fresh()), trans('admin/maintenances/message.complete.success')));
}
public function history(Request $request, Maintenance $maintenance): JsonResponse|array
{
$this->authorize('history', $maintenance);
$historyQuery = $maintenance->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
public function notesIndex(Maintenance $maintenance): JsonResponse
{
$this->authorize('journal', $maintenance);
$notes = Actionlog::with('user:id,username')
->where('item_type', Maintenance::class)
->where('item_id', $maintenance->id)
->where('action_type', 'note added')
->orderBy('created_at', 'desc')
->get(['id', 'created_at', 'note', 'created_by', 'item_id', 'item_type', 'action_type']);
$notesArray = $notes->map(fn ($note) => [
'id' => $note->id,
'created_at' => $note->created_at,
'note' => $note->note,
'created_by' => $note->created_by,
'username' => $note->user?->username,
'item_id' => $note->item_id,
'item_type' => $note->item_type,
'action_type' => $note->action_type,
]);
return response()->json(Helper::formatStandardApiResponse('success', ['notes' => $notesArray, 'maintenance_id' => $maintenance->id]));
}
public function notesStore(Request $request, Maintenance $maintenance): JsonResponse
{
$this->authorize('update', $maintenance);
if (! $request->filled('note')) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('validation.required', ['attribute' => 'note'])), 422);
}
$logaction = new Actionlog;
$logaction->item_type = Maintenance::class;
$logaction->created_by = auth()->id();
$logaction->item_id = $maintenance->id;
$logaction->note = $request->input('note');
if ($logaction->logaction('note added')) {
return response()->json(Helper::formatStandardApiResponse('success', ['note' => $logaction->note, 'item_id' => $maintenance->id], trans('general.note_added')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'Something went wrong'), 500);
}
}
@@ -6,6 +6,7 @@ use App\Actions\Manufacturers\DeleteManufacturerAction;
use App\Exceptions\ItemStillHasChildren;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ManufacturersTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -28,7 +29,7 @@ class ManufacturersController extends Controller
*
* @return Response
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Manufacturer::class);
$allowed_columns = [
@@ -81,8 +82,9 @@ class ManufacturersController extends Controller
$manufacturers->onlyTrashed();
}
if ($request->filled('search')) {
$manufacturers = $manufacturers->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$manufacturers->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -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')]));
+181 -9
View File
@@ -2,11 +2,23 @@
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Maintenance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ReportsController extends Controller
{
@@ -17,32 +29,54 @@ class ReportsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('activity.view');
// If the user doesn't have permission to view the item or the target,
// then they shouldn't be able to see the activity log for that item or target,
// but if they have the general activity view permission,
// then they can see all activity logs regardless of the item or target.
if ((! Gate::allows('activity.view')) && (($request->filled('target_type') && $request->filled('target_id')) || ($request->filled('item_type') && $request->filled('item_id')))) {
if (($request->filled('target_type')) && ($request->filled('target_id'))) {
$targetClass = Helper::normalizeFullModelName(request()->input('target_type'));
$target = $targetClass::withTrashed()->find(request()->input('target_id'));
$this->authorize('view', $target ?? $targetClass);
}
if (($request->filled('item_type')) && ($request->filled('item_id'))) {
$itemClass = Helper::normalizeFullModelName(request()->input('item_type'));
$item = $itemClass::withTrashed()->find(request()->input('item_id'));
$this->authorize('view', $item ?? $itemClass);
}
} else {
$this->authorize('activity.view');
}
$actionlogs = Actionlog::with('item', 'user', 'adminuser', 'target', 'location');
if ($request->filled('search')) {
$actionlogs = $actionlogs->TextSearch(e($request->input('search')));
}
if (($request->filled('target_type')) && ($request->filled('target_id'))) {
$actionlogs = $actionlogs->where('target_id', '=', $request->input('target_id'))
->where('target_type', '=', 'App\\Models\\'.ucwords($request->input('target_type')));
->where('target_type', '=', Helper::normalizeFullModelName($request->input('target_type')));
}
if (($request->filled('item_type')) && ($request->filled('item_id'))) {
$actionlogs = $actionlogs->where(function ($query) use ($request) {
$query->where('item_id', '=', $request->input('item_id'))
->where('item_type', '=', 'App\\Models\\'.ucwords($request->input('item_type')))
->where('item_type', '=', Helper::normalizeFullModelName($request->input('item_type')))
->orWhere(function ($query) use ($request) {
$query->where('target_id', '=', $request->input('item_id'))
->where('target_type', '=', 'App\\Models\\'.ucwords($request->input('item_type')));
->where('target_type', '=', Helper::normalizeFullModelName($request->input('item_type')));
});
});
}
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$actionlogs->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('action_type')) {
$actionlogs = $actionlogs->where('action_type', '=', $request->input('action_type'));
}
@@ -99,5 +133,143 @@ class ReportsController extends Controller
$actionlogs = $actionlogs->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
/**
* Returns time-series data for the reports overview charts.
*
* Accepts ?days=N (preset, default 30) OR ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD.
* Also returns the immediately preceding period of equal length for comparison lines.
*/
public function activityChart(Request $request): JsonResponse
{
$this->authorize('reports.view');
$allowedDays = [7, 14, 30, 60, 90, 180, 365];
if ($request->filled('start_date') && $request->filled('end_date')) {
$curStart = Carbon::parse($request->input('start_date'))->startOfDay();
$curEnd = Carbon::parse($request->input('end_date'))->endOfDay();
if ($curEnd->lt($curStart)) {
[$curStart, $curEnd] = [$curEnd, $curStart];
}
$days = max(1, (int) $curStart->diffInDays($curEnd) + 1);
} else {
$days = in_array((int) $request->input('days'), $allowedDays) ? (int) $request->input('days') : 30;
$curEnd = Carbon::today()->endOfDay();
$curStart = Carbon::today()->subDays($days - 1)->startOfDay();
}
$prevEnd = $curStart->copy()->subSecond()->endOfDay();
$prevStart = $prevEnd->copy()->subDays($days - 1)->startOfDay();
$buildDates = function (Carbon $start, Carbon $end): array {
$dates = [];
for ($d = $start->copy(); $d->lte($end); $d->addDay()) {
$dates[] = $d->toDateString();
}
return $dates;
};
$curDates = $buildDates($curStart, $curEnd);
$prevDates = $buildDates($prevStart, $prevEnd);
$pluckAction = function (string $actionType, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// withTrashed() ensures records deleted after creation still appear in their creation-period counts.
$pluckCreated = function (string $modelClass, Carbon $start, Carbon $end): array {
return $modelClass::withTrashed()
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Maintenance has no company_id column and no CompanyableTrait, so scope through
// its asset relationship — whereHas('asset') applies Asset's FMCS global scope.
$pluckMaintenances = function (Carbon $start, Carbon $end): array {
return Maintenance::withTrashed()
->whereHas('asset')
->whereBetween('maintenances.created_at', [$start, $end])
->selectRaw('DATE(maintenances.created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Filters by both action_type and item_type for per-category checkout/checkin counts.
$pluckActionByType = function (string $actionType, string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$pluckDeletedUsers = function (Carbon $start, Carbon $end): array {
return User::withTrashed()
->whereNotNull('deleted_at')
->whereBetween('deleted_at', [$start, $end])
->selectRaw('DATE(deleted_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Catches both 'checkin' and 'checkin from' action types used across different item types.
$pluckCheckinsByType = function (string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::whereIn('action_type', ['checkin', 'checkin from'])
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$fill = fn (array $raw, array $dates) => array_map(fn ($d) => (int) ($raw[$d] ?? 0), $dates);
$datasets = [];
foreach ([
'new_users' => fn ($s, $e) => $pluckCreated(User::class, $s, $e),
'deleted_users' => fn ($s, $e) => $pluckDeletedUsers($s, $e),
'asset_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Asset::class, $s, $e),
'asset_checkins' => fn ($s, $e) => $pluckCheckinsByType(Asset::class, $s, $e),
'new_assets' => fn ($s, $e) => $pluckCreated(Asset::class, $s, $e),
'new_maintenances' => fn ($s, $e) => $pluckMaintenances($s, $e),
'new_audits' => fn ($s, $e) => $pluckAction('audit', $s, $e),
'component_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Component::class, $s, $e),
'component_checkins' => fn ($s, $e) => $pluckCheckinsByType(Component::class, $s, $e),
'new_components' => fn ($s, $e) => $pluckCreated(Component::class, $s, $e),
'consumable_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Consumable::class, $s, $e),
'consumable_checkins' => fn ($s, $e) => $pluckCheckinsByType(Consumable::class, $s, $e),
'new_consumables' => fn ($s, $e) => $pluckCreated(Consumable::class, $s, $e),
'license_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', LicenseSeat::class, $s, $e),
'license_checkins' => fn ($s, $e) => $pluckCheckinsByType(LicenseSeat::class, $s, $e),
'new_licenses' => fn ($s, $e) => $pluckCreated(License::class, $s, $e),
'accessory_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Accessory::class, $s, $e),
'accessory_checkins' => fn ($s, $e) => $pluckCheckinsByType(Accessory::class, $s, $e),
'new_accessories' => fn ($s, $e) => $pluckCreated(Accessory::class, $s, $e),
] as $key => $query) {
$datasets[$key] = $fill($query($curStart, $curEnd), $curDates);
$datasets['prev_'.$key] = $fill($query($prevStart, $prevEnd), $prevDates);
}
return response()->json(array_merge([
'labels' => array_map(fn ($d) => Carbon::parse($d)->format('M j'), $curDates),
'prev_label' => $prevStart->format('M j').' '.$prevEnd->format('M j'),
], $datasets));
}
}
@@ -162,6 +162,13 @@ class SettingsController extends Controller
public function ajaxTestEmail(): JsonResponse
{
if (! config('app.lock_passwords')) {
if (config('mail.reply_to.address') == '') {
Log::debug('MAIL_REPLYTO_ADDR not set in env. Skipping mail test.');
return response()->json(['message' => trans('admin/settings/general.mail_test_no_email')], 403);
}
try {
Notification::send(Setting::first(), new MailTest);
Log::debug('Attempting to sending to '.config('mail.reply_to.address'));
@@ -286,6 +293,11 @@ class SettingsController extends Controller
*/
public function downloadBackup($file): JsonResponse|BinaryFileResponse
{
$file = $this->sanitizeBackupFilename($file);
if ($file === null) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_not_found')), 404);
}
$path = storage_path('app/backups');
@@ -329,4 +341,21 @@ class SettingsController extends Controller
}
}
private function sanitizeBackupFilename(mixed $filename): ?string
{
$filename = trim((string) $filename);
if ($filename === '' || str_contains($filename, "\0")) {
return null;
}
$sanitized = basename($filename);
if (($sanitized === '') || ($sanitized === '.') || ($sanitized === '..')) {
return null;
}
return ($sanitized === $filename) ? $sanitized : null;
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\PieChartTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -23,7 +24,7 @@ class StatuslabelsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('view', Statuslabel::class);
$allowed_columns = [
@@ -38,8 +39,9 @@ class StatuslabelsController extends Controller
$statuslabels = Statuslabel::with('adminuser')->withCount('assets as assets_count');
if ($request->filled('search')) {
$statuslabels = $statuslabels->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$statuslabels->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -11,6 +11,7 @@ use App\Exceptions\ItemStillHasLicenses;
use App\Exceptions\ItemStillHasMaintenances;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\SelectlistTransformer;
use App\Http\Transformers\SuppliersTransformer;
@@ -31,7 +32,7 @@ class SuppliersController extends Controller
*
* @return Response
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('view', Supplier::class);
$allowed_columns = [
@@ -67,8 +68,9 @@ class SuppliersController extends Controller
->withCount('consumables as consumables_count')
->with('adminuser');
if ($request->filled('search')) {
$suppliers->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$suppliers->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -32,7 +32,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('view', $object);
$this->authorize('files', $object);
if (! $object) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
@@ -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';
@@ -91,7 +91,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('update', $object);
$this->authorize('files', $object);
if (! $object) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
@@ -141,7 +141,7 @@ class UploadedFilesController extends Controller
{
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('view', $object);
$this->authorize('files', $object);
if (! $object) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
@@ -153,7 +153,7 @@ class UploadedFilesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_id')), 200);
}
if (! Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
if (! Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.file_not_found'), 200));
}
@@ -162,10 +162,10 @@ class UploadedFilesController extends Controller
'Content-Disposition' => 'inline',
];
return Storage::download(self::$map_storage_path[$object_type].'/'.$log->filename, $log->filename, $headers);
return Storage::download(self::$map_storage_path[$object_type].$log->filename, $log->filename, $headers);
}
return StorageHelper::downloader(self::$map_storage_path[$object_type].'/'.$log->filename);
return StorageHelper::downloader(self::$map_storage_path[$object_type].$log->filename);
}
@@ -186,7 +186,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('update', $object);
$this->authorize('files', $object);
if (! $object) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
@@ -202,8 +202,8 @@ class UploadedFilesController extends Controller
if ($log) {
// Check the file actually exists, and delete it
if (Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
Storage::delete(self::$map_storage_path[$object_type].'/'.$log->filename);
if (Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
Storage::delete(self::$map_storage_path[$object_type].$log->filename);
}
// Delete the record of the file
if ($log->logUploadDelete($object, $log->filename)) {
+89 -71
View File
@@ -2,6 +2,8 @@
namespace App\Http\Controllers\Api;
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
use App\Actions\Permissions\PreserveUnauthorizedPrivilegedPermissionsAction;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteUserRequest;
@@ -20,6 +22,7 @@ use App\Models\Asset;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\License;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\CurrentInventory;
use App\Notifications\WelcomeNotification;
@@ -49,7 +52,6 @@ class UsersController extends Controller
'users.address',
'users.avatar',
'users.city',
'users.company_id',
'users.country',
'users.created_by',
'users.created_at',
@@ -87,7 +89,7 @@ class UsersController extends Controller
])->with('manager')
->with('groups')
->with('userloc')
->with('company')
->with('companies')
->with('department')
->with('createdBy')
->withCount([
@@ -171,10 +173,9 @@ class UsersController extends Controller
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$users->ByFilter($filter);
} elseif ($request->filled('search')) {
$users->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$users->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('activated')) {
@@ -190,7 +191,7 @@ class UsersController extends Controller
}
if ($request->filled('company_id')) {
$users = $users->where('users.company_id', '=', $request->input('company_id'));
$users = $users->whereHas('companies', fn ($q) => $q->where('companies.id', $request->input('company_id')));
}
if ($request->filled('phone')) {
@@ -379,6 +380,8 @@ class UsersController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$users = User::select(
[
'users.id',
@@ -393,6 +396,22 @@ class UsersController extends Controller
]
)->where('show_in_list', '=', '1');
// When FMCS is enabled, automatically scope to companies the acting user belongs to.
// scopeCompanyables is a no-op for superusers and when FMCS is disabled.
$users = Company::scopeCompanyables($users, 'company_id', 'users');
// Allow further narrowing to a specific company passed via data-company-ids on the select.
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
if (! empty($companyIds)) {
$users = Company::scopeUsersByCompanyIds($users, $companyIds);
}
}
if ($request->filled('excludeId')) {
$users->where('users.id', '!=', (int) $request->input('excludeId'));
}
if ($request->filled('search')) {
$users = $users->where(function ($query) use ($request) {
$query->SimpleNameSearch($request->input('search'))
@@ -437,27 +456,16 @@ class UsersController extends Controller
{
$this->authorize('create', User::class);
$authenticatedUser = auth()->user();
$user = new User;
$user->fill($request->all());
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$user->created_by = auth()->id();
if ($request->has('permissions')) {
$permissions_array = $request->input('permissions');
if (! auth()->user()->isSuperUser()) {
if ((is_array($permissions_array)) && (array_key_exists('superuser', $permissions_array))) {
unset($permissions_array['superuser']);
}
}
if (! auth()->user()->isAdmin()) {
if ((is_array($permissions_array)) && (array_key_exists('admin', $permissions_array))) {
unset($permissions_array['admin']);
}
}
$user->permissions = $permissions_array;
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
authenticatedUser: $authenticatedUser,
));
}
//
@@ -495,6 +503,12 @@ class UsersController extends Controller
$user->groups()->sync($request->input('groups'));
}
// Sync company memberships from company_ids[] or fall back to scalar company_id
$companyIds = array_filter(
(array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))
);
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser(array_map('intval', $companyIds)));
return response()->json(Helper::formatStandardApiResponse('success', (new UsersTransformer)->transformUser($user), trans('admin/users/message.success.create')));
}
@@ -536,6 +550,8 @@ class UsersController extends Controller
{
$this->authorize('update', $user);
$authenticatedUser = auth()->user();
/**
* This is a janky hack to prevent people from changing admin demo user data on the public demo.
* The $ids 1 and 2 are special since they are seeded as superadmins in the demo seeder.
@@ -571,45 +587,17 @@ class UsersController extends Controller
}
if ($request->has('permissions')) {
$permissions_array = $request->input('permissions');
$orig_permissions_array = $user->decodePermissions();
// Strip out the individual superuser permission if the API user isn't a superadmin
if (! auth()->user()->isSuperUser()) {
if (is_array($orig_permissions_array)) {
if (array_key_exists('superuser', $orig_permissions_array)) {
$permissions_array['superuser'] = $orig_permissions_array['superuser'];
}
}
}
// Strip out the individual admin permission if the API user isn't an admin
if ((! auth()->user()->isAdmin()) && (! auth()->user()->isSuperUser())) {
if (is_array($orig_permissions_array)) {
if (array_key_exists('admin', $orig_permissions_array)) {
$permissions_array['admin'] = $orig_permissions_array['admin'];
}
}
}
// This is going to update the whole thing, not just what was passed
$user->permissions = $permissions_array;
// This is going to update the whole thing, not just what was passed.
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
authenticatedUser: $authenticatedUser,
originalPermissions: NormalizePermissionsPayloadAction::run($user->decodePermissions()),
targetUser: $user,
));
}
}
if ($request->filled('display_name')) {
$user->display_name = $request->input('display_name');
}
if ($request->filled('company_id')) {
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
}
if ($user->id == $request->input('manager_id')) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'You cannot be your own manager'));
}
@@ -638,6 +626,18 @@ class UsersController extends Controller
$user->groups()->sync($request->input('groups'));
}
// company_ids (new format) = full replacement sync.
// Legacy company_id = add without removing other associations.
if ($request->has('company_ids')) {
$companyIds = array_filter(array_map('intval', (array) $request->input('company_ids')));
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
} elseif ($request->filled('company_id')) {
$filtered = Company::getIdsForCurrentUser([(int) $request->input('company_id')]);
if (! empty($filtered)) {
$user->companies()->syncWithoutDetaching($filtered);
}
}
return response()->json(Helper::formatStandardApiResponse('success', (new UsersTransformer)->transformUser($user), trans('admin/users/message.success.update')));
}
@@ -840,21 +840,27 @@ class UsersController extends Controller
try {
$user = User::find($request->input('id'));
$this->authorize('update', $user);
$user->two_factor_secret = null;
$user->two_factor_enrolled = 0;
$user->saveQuietly();
// Log the reset
$logaction = new Actionlog;
$logaction->target_type = User::class;
$logaction->target_id = $user->id;
$logaction->item_type = User::class;
$logaction->item_id = $user->id;
$logaction->created_at = date('Y-m-d H:i:s');
$logaction->created_by = auth()->id();
$logaction->logaction('2FA reset');
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
$user->two_factor_secret = null;
$user->two_factor_enrolled = 0;
$user->saveQuietly();
// Log the reset
$logaction = new Actionlog;
$logaction->target_type = User::class;
$logaction->target_id = $user->id;
$logaction->item_type = User::class;
$logaction->item_id = $user->id;
$logaction->created_at = date('Y-m-d H:i:s');
$logaction->created_by = auth()->id();
$logaction->logaction('2FA reset');
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
}
return response()->json(['message' => trans('general.unauthorized')], 500);
} catch (\Exception $e) {
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_error')], 500);
}
@@ -967,4 +973,16 @@ class UsersController extends Controller
return response()->json(Helper::formatStandardApiResponse('success', null, $ldap_results['summary']), 200);
}
public function history(Request $request, User $user): JsonResponse|array
{
$this->authorize('history', $user);
$historyQuery = $user->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -9,7 +9,6 @@ use App\Models\Actionlog;
use App\Models\AssetModel;
use App\Models\CustomField;
use App\Models\SnipeModel;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@@ -10,6 +10,7 @@ use App\Http\Traits\MigratesLegacyAssetLocations;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\LicenseSeat;
use App\Models\Statuslabel;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
@@ -56,9 +57,16 @@ class AssetCheckinController extends Controller
default => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.user')]),
};
$deployableStatusIds = array_map('intval', array_keys(Helper::deployableStatusLabelList()));
$selectedStatusId = old('status_id');
$showRequestableToggle = is_numeric($selectedStatusId)
&& in_array((int) $selectedStatusId, $deployableStatusIds, true);
return view('hardware/checkin', compact('asset', 'target_option'))
->with('item', $asset)
->with('statusLabel_list', Helper::statusLabelList())
->with('deployable_status_ids', $deployableStatusIds)
->with('show_requestable_toggle', $showRequestableToggle)
->with('backto', $backto)
->with('table_name', 'Assets');
}
@@ -76,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'));
}
@@ -107,6 +115,19 @@ class AssetCheckinController extends Controller
$asset->status_id = e($request->input('status_id'));
}
$selectedStatusId = $request->filled('status_id')
? (int) $request->input('status_id')
: (int) $asset->status_id;
$isDeployableStatus = Statuslabel::query()
->whereKey($selectedStatusId)
->where('deployable', 1)
->exists();
if ($request->boolean('set_requestable') && $isDeployableStatus) {
$asset->requestable = true;
}
// Add any custom fields that should be included in the checkout
$asset->customFieldsForCheckinCheckout('display_checkin');
@@ -114,12 +135,16 @@ class AssetCheckinController extends Controller
$asset->location_id = $asset->rtd_location_id;
if ($request->filled('location_id')) {
Log::debug('NEW Location ID: '.$request->input('location_id'));
$asset->location_id = $request->input('location_id');
if ($request->input('update_default_location') == 0) {
$asset->rtd_location_id = $request->input('location_id');
if ($request->has('location_id')) {
if ($request->filled('location_id')) {
Log::debug('NEW Location ID: '.$request->input('location_id'));
$asset->location_id = $request->input('location_id');
if ($request->input('update_default_location') == 0) {
$asset->rtd_location_id = $request->input('location_id');
}
} else {
// Explicitly submitted as empty — clear the location
$asset->location_id = null;
}
}
@@ -154,6 +179,10 @@ class AssetCheckinController extends Controller
$asset->customFieldsForCheckinCheckout('display_checkin');
if ($asset->save()) {
// Update the location of any child assets
Asset::where('assigned_type', Asset::class)
->where('assigned_to', $asset->id)
->update(['location_id' => $asset->location_id]);
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));
@@ -164,4 +193,34 @@ class AssetCheckinController extends Controller
// Redirect to the asset management page with error
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.checkin.error').$asset->getErrors());
}
/**
* This would only be used if the target is actually hard-deleted
* and literally does not exist in the database anymore. This will null out the assigned_to
* and assigned_type fields, but will not trigger any events or do any of the other things that a
* normal checkin would do, since the target itself is now invalid.
*/
public function forceCheckin(Asset $asset)
{
$this->authorize('checkin', $asset);
if (! $asset->hasOrphanedAssignment()) {
return redirect()->route('hardware.show', $asset->id)
->with('error', trans('admin/hardware/message.checkin.force_checkin_not_orphaned'));
}
$asset->assigned_to = null;
$asset->assigned_type = null;
if ($asset->save()) {
$asset->logForceCheckin();
return redirect()->route('hardware.show', $asset->id)
->with('success', trans('admin/hardware/message.checkin.force_checkin_orphaned_success'));
}
return redirect()->route('hardware.show', $asset->id)
->with('error', trans('admin/hardware/message.checkin.force_checkin_error'));
}
}
@@ -4,18 +4,19 @@ namespace App\Http\Controllers\Assets;
use App\Exceptions\CheckoutNotAllowed;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\Setting;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\RedirectResponse;
class AssetCheckoutController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Returns a view that presents a form to check an asset out to a
@@ -101,6 +102,10 @@ class AssetCheckoutController extends Controller
$asset->status_id = $request->input('status_id');
}
if ($request->boolean('set_not_requestable')) {
$asset->requestable = false;
}
if (! empty($asset->licenseseats->all())) {
if (request('checkout_to_type') == 'user') {
foreach ($asset->licenseseats as $seat) {
@@ -113,18 +118,57 @@ 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(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'), null, $request->boolean('sign_in_place'))) {
// When sign_in_place is requested and the target is a user, redirect to the
// acceptance/signature page so the user can sign in person. The signature is
// attributed to the target user, not the admin.
if ($request->boolean('sign_in_place') && $target instanceof User) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->where('assigned_to_id', $target->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($asset);
$acceptance->assignedTo()->associate($target);
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $asset->id,
'sign_in_place_resource_type' => 'Assets',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/hardware/message.checkout.success'));
}
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'))) {
return Helper::getRedirectOption($request, $asset->id, 'Assets')
->with('success', trans('admin/hardware/message.checkout.success'));
}
@@ -66,7 +66,8 @@ class AssetsController extends Controller
public function index(Request $request): View
{
$this->authorize('index', Asset::class);
$company = Company::find($request->input('company_id'));
$companyId = $request->input('company_id');
$company = is_scalar($companyId) ? Company::find($companyId) : null;
return view('hardware/index')->with('company', $company);
}
@@ -357,11 +358,26 @@ 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');
$total_asset_cost = ($asset->assignedAssets()?->AssetsForShow()) ? $asset->assignedAssets()?->AssetsForShow()?->sum('purchase_cost') : 0;
$total_license_cost = ($asset->licenses) ? $asset->licenses->sum('purchase_cost') : 0;
$total_accessory_cost = ($asset->accessories) ? $asset->accessories()->sum('purchase_cost') : 0;
$total_component_cost = ($asset->components) ? $asset->components->sum('calculated_purchase_cost') : 0;
$total_cost_for_asset = $asset->purchase_cost + $total_maintenance_cost + $total_asset_cost + $total_license_cost + $total_accessory_cost + $total_component_cost;
return view('hardware/view', compact('asset', 'qr_code', 'settings'))
->with('use_currency', $use_currency)->with('audit_log', $audit_log);
->with('total_maintenance_cost', $total_maintenance_cost)
->with('total_asset_cost', $total_asset_cost)
->with('total_license_cost', $total_license_cost)
->with('total_accessory_cost', $total_accessory_cost)
->with('total_component_cost', $total_component_cost)
->with('total_cost_for_asset', $total_cost_for_asset)
->with('use_currency', $use_currency)
->with('audit_log', $audit_log);
}
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
@@ -427,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);
@@ -495,7 +511,7 @@ class AssetsController extends Controller
// Validate required serial based on model setting
if ($model && $model->require_serial === 1 && empty($serial[1])) {
return redirect()->to(Helper::getRedirectOption($request, $asset->id, 'Assets'))
return Helper::getRedirectOption($request, $asset->id, 'Assets')
->with('warning', trans('admin/hardware/form.serial_required_post_model_update', [
'asset_model' => $model->name,
]));
@@ -533,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);
}
@@ -551,11 +567,12 @@ class AssetsController extends Controller
*
* @since [v3.0]
*/
public function getAssetBySerial(Request $request): RedirectResponse
public function getAssetBySerial(Request $request, $serial = null): RedirectResponse
{
$serial = $serial ?: $request->input('serial');
$topsearch = ($request->input('topsearch') == 'true');
if (! $asset = Asset::where('serial', '=', $request->input('serial'))->first()) {
if (! $asset = Asset::where('serial', '=', $serial)->first()) {
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
}
$this->authorize('view', $asset);
@@ -2,19 +2,25 @@
namespace App\Http\Controllers\Assets;
use App\Events\CheckoutableCheckedIn;
use App\Events\CheckoutablesCheckedOutInBulk;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\CustomField;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\Statuslabel;
use App\Models\User;
use App\View\Label;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -26,7 +32,7 @@ use Illuminate\Support\Facades\Log;
class BulkAssetsController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Display the bulk edit page.
@@ -72,6 +78,16 @@ class BulkAssetsController extends Controller
return redirect()->route('hardware.bulkcheckout.show');
}
if ($request->input('bulk_actions') === 'checkin') {
$referer = request()->headers->get('referer');
if ($referer && parse_url($referer, PHP_URL_HOST) === parse_url(config('app.url'), PHP_URL_HOST)) {
redirect()->setIntendedUrl($referer);
}
$request->session()->flashInput(['selected_assets' => $asset_ids]);
return redirect()->route('hardware.bulkcheckin.show');
}
if ($request->input('bulk_actions') === 'maintenance') {
$request->session()->flashInput(['selected_assets' => $asset_ids]);
@@ -371,7 +387,7 @@ class BulkAssetsController extends Controller
}
if ($request->filled('company_id')) {
$this->update_array['company_id'] = $request->input('company_id');
$this->update_array['company_id'] = Company::getIdForCurrentUser($request->input('company_id'));
if ($request->input('company_id') == 'clear') {
$this->update_array['company_id'] = null;
}
@@ -406,7 +422,7 @@ class BulkAssetsController extends Controller
// Otherwise we need to make sure the status type is still a deployable one.
$unassigned = $asset->assigned_to == '';
$deployable = $updated_status->deployable == '1' && $asset->assetstatus?->deployable == '1';
$deployable = $updated_status->deployable == '1' && $asset->status?->deployable == '1';
$pending = $updated_status->pending === 1;
if ($unassigned || $deployable || $pending) {
@@ -672,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'));
}
}
}
@@ -715,6 +738,10 @@ class BulkAssetsController extends Controller
$asset->status_id = $request->input('status_id');
}
if ($request->boolean('set_not_requestable')) {
$asset->requestable = false;
}
$checkout_success = $asset->checkOut($target, $admin, $checkout_at, $expected_checkin, e($request->input('note')), $asset->name, null);
// TODO - I think this logic is duplicated in the checkOut method?
@@ -754,6 +781,112 @@ class BulkAssetsController extends Controller
}
/**
* Show Bulk Checkin Page
*/
public function showCheckin(): View
{
$this->authorize('checkin', Asset::class);
$notAssigned = collect();
if (old('selected_assets') && is_array(old('selected_assets'))) {
$assets = Asset::withTrashed()->findMany(old('selected_assets'));
[$assigned, $notAssigned] = $assets->partition(function (Asset $asset) {
return $asset->assigned_to;
});
session()->flashInput(['selected_assets' => $assigned->pluck('id')->values()->toArray()]);
}
$do_not_change = ['' => trans('general.do_not_change')];
$status_label_list = $do_not_change + Helper::statusLabelList();
return view('hardware/bulk-checkin', [
'statusLabel_list' => $status_label_list,
'removed_assets' => $notAssigned,
]);
}
/**
* Process Multiple Checkin Request
*/
public function storeCheckin(Request $request): RedirectResponse
{
$this->authorize('checkin', Asset::class);
if (! is_array($request->input('selected_assets'))) {
return redirect()->route('hardware.bulkcheckin.show')->withInput()->with('error', trans('admin/hardware/message.multi-checkin.no_assets_selected'));
}
$asset_ids = array_filter($request->input('selected_assets'));
$assets = Asset::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')) {
$checkin_at = $request->input('checkin_at');
}
$errors = [];
$admin = auth()->user();
DB::transaction(function () use ($assets, $admin, $checkin_at, $request, &$errors) {
foreach ($assets as $asset) {
$this->authorize('checkin', $asset);
if (is_null($asset->assignedTo)) {
continue;
}
$target = $asset->assignedTo;
$originalValues = $asset->getRawOriginal();
$asset->expected_checkin = null;
$asset->assignedTo()->disassociate($asset);
$asset->accepted = null;
if ($request->filled('status_id')) {
$asset->status_id = $request->input('status_id');
}
$asset->location_id = $asset->rtd_location_id;
$asset->last_checkin = $checkin_at;
if ($request->boolean('checkin_licenses')) {
$asset->licenseseats->each(function (LicenseSeat $seat) {
$seat->update(['assigned_to' => null]);
});
}
CheckoutAcceptance::pending()->whereHasMorph('checkoutable', [Asset::class], function (Builder $query) use ($asset) {
$query->where('id', $asset->id);
})->get()->each->delete();
if ($asset->save()) {
if ($request->boolean('checkin_child_assets')) {
Asset::where('assigned_type', Asset::class)
->where('assigned_to', $asset->id)
->update(['location_id' => $asset->location_id]);
}
event(new CheckoutableCheckedIn($asset, $target, $admin, $request->input('note'), $checkin_at, $originalValues));
} else {
$errors = array_merge_recursive($errors, $asset->getErrors()->toArray());
}
}
});
if (! $errors) {
return redirect()->intended(route('hardware.index'))->with('success', trans_choice('admin/hardware/message.multi-checkin.success', count($asset_ids)));
}
return redirect()->route('hardware.bulkcheckin.show')->withInput()
->with('error', trans_choice('admin/hardware/message.multi-checkin.error', count($asset_ids)))
->withErrors($errors);
}
public function restore(Request $request): RedirectResponse
{
$this->authorize('update', Asset::class);
+10 -8
View File
@@ -106,15 +106,21 @@ class LoginController extends Controller
if ($saml->isEnabled() && ! empty($samlData)) {
try {
$user = $saml->samlLogin($samlData);
$notValidAfter = new \Carbon\Carbon(@$samlData['assertionNotOnOrAfter']);
if (\Carbon::now()->greaterThanOrEqualTo($notValidAfter)) {
abort(400, 'Expired SAML Assertion');
}
if (SamlNonce::where('nonce', @$samlData['nonce'])->count() > 0) {
abort(400, 'Assertion has already been used');
try {
SamlNonce::create([
'nonce' => $samlData['nonce'],
'not_valid_after' => $notValidAfter,
]);
} catch (\Exception $e) {
\Log::error($e);
abort(400, 'Assertion has already been used.');
}
Log::debug('okay, fine, this is a new nonce then. Good for you.');
if (! is_null($user)) {
Auth::login($user);
} else {
@@ -128,10 +134,6 @@ class LoginController extends Controller
$user->last_login = \Carbon::now();
$user->saveQuietly();
}
$s = new SamlNonce;
$s->nonce = @$samlData['nonce'];
$s->not_valid_after = $notValidAfter;
$s->save();
} catch (\Exception $e) {
Log::debug('There was an error authenticating the SAML user: '.$e->getMessage());
@@ -433,7 +435,7 @@ class LoginController extends Controller
$user->saveQuietly();
$request->session()->put('2fa_authed', $user->id);
return redirect()->route('home')->with('success', trans('auth/message.signin.success'));
return redirect()->intended()->with('success', trans('auth/message.signin.success'));
}
return redirect()->route('two-factor')->with('error', trans('auth/message.two_factor.invalid_code'));
+2 -1
View File
@@ -74,7 +74,7 @@ class SamlController extends Controller
public function login(Request $request)
{
$auth = $this->saml->getAuth();
$ssoUrl = $auth->login(null, [], false, false, false, false);
$ssoUrl = $auth->login(session()->get('url.intended'), [], false, false, false, false);
return redirect()->away($ssoUrl);
}
@@ -96,6 +96,7 @@ class SamlController extends Controller
$saml = $this->saml;
$auth = $saml->getAuth();
$saml_exception = false;
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');
@@ -4,7 +4,8 @@ namespace App\Http\Controllers\Components;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreComponentRequest;
use App\Http\Requests\UpdateComponentRequest;
use App\Models\Company;
use App\Models\Component;
use Illuminate\Auth\Access\AuthorizationException;
@@ -12,7 +13,6 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
/**
* This class controls all actions related to Components for
@@ -74,7 +74,7 @@ class ComponentsController extends Controller
*
* @throws AuthorizationException
*/
public function store(ImageUploadRequest $request)
public function store(StoreComponentRequest $request)
{
$this->authorize('create', Component::class);
$component = new Component;
@@ -148,21 +148,10 @@ class ComponentsController extends Controller
*
* @since [v3.0]
*/
public function update(ImageUploadRequest $request, Component $component)
public function update(UpdateComponentRequest $request, Component $component)
{
$min = $component->numCheckedOut();
$validator = Validator::make($request->all(), [
'qty' => "required|numeric|min:$min",
]);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput();
}
$this->authorize('update', $component);
// Update the component data
$component->name = $request->input('name');
$component->category_id = $request->input('category_id');
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Consumables;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
@@ -95,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'));
@@ -116,12 +125,46 @@ class ConsumableCheckoutController extends Controller
$request->input('note'),
[],
$consumable->checkout_qty,
$request->boolean('sign_in_place'),
));
$request->request->add(['checkout_to_type' => 'user']);
$request->request->add(['assigned_user' => $user->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
// When sign_in_place is requested, redirect to the acceptance/signature page
// so the user can sign in person. The signature is attributed to the target user.
if ($request->boolean('sign_in_place')) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', Consumable::class)
->where('checkoutable_id', $consumable->id)
->where('assigned_to_id', $user->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($consumable);
$acceptance->assignedTo()->associate($user);
$acceptance->qty = $quantity;
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $consumable->id,
'sign_in_place_resource_type' => 'Consumables',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/consumables/message.checkout.success'));
}
// Redirect to the new consumable page
return Helper::getRedirectOption($request, $consumable->id, 'Consumables')
+8
View File
@@ -26,8 +26,10 @@ namespace App\Http\Controllers;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Company;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\Department;
use App\Models\License;
use App\Models\Location;
use App\Models\Maintenance;
@@ -46,6 +48,8 @@ abstract class Controller extends BaseController
public static $map_object_type = [
'accessories' => Accessory::class,
'companies' => Company::class,
'departments' => Department::class,
'maintenances' => Maintenance::class,
'assets' => Asset::class,
'audits' => Asset::class,
@@ -64,6 +68,8 @@ abstract class Controller extends BaseController
'maintenances' => 'private_uploads/maintenances/',
'assets' => 'private_uploads/assets/',
'audits' => 'private_uploads/audits/',
'departments' => 'private_uploads/departments/',
'companies' => 'private_uploads/companies/',
'components' => 'private_uploads/components/',
'consumables' => 'private_uploads/consumables/',
'hardware' => 'private_uploads/assets/',
@@ -79,6 +85,8 @@ abstract class Controller extends BaseController
'maintenances' => 'maintenance',
'assets' => 'asset',
'audits' => 'audits',
'companies' => 'company',
'departments' => 'department',
'components' => 'component',
'consumables' => 'consumable',
'hardware' => 'asset',
@@ -54,7 +54,7 @@ class DepartmentsController extends Controller
$department->created_by = auth()->id();
$department->manager_id = ($request->filled('manager_id') ? $request->input('manager_id') : null);
$department->location_id = ($request->filled('location_id') ? $request->input('location_id') : null);
$department->company_id = ($request->filled('company_id') ? $request->input('company_id') : null);
$department->company_id = ($request->filled('company_id') ? Company::getIdForCurrentUser($request->input('company_id')) : null);
$department->tag_color = $request->input('tag_color');
$department->notes = $request->input('notes');
$department = $request->handleImages($department);
@@ -107,12 +107,8 @@ class DepartmentsController extends Controller
*
* @since [v4.0]
*/
public function destroy($id): RedirectResponse
public function destroy(Department $department): RedirectResponse
{
if (is_null($department = Department::find($id))) {
return redirect()->to(route('departments.index'))->with('error', trans('admin/departments/message.not_found'));
}
$this->authorize('delete', $department);
if ($department->users->count() > 0) {
@@ -168,7 +164,7 @@ class DepartmentsController extends Controller
$department->fill($request->all());
$department->manager_id = ($request->filled('manager_id') ? $request->input('manager_id') : null);
$department->location_id = ($request->filled('location_id') ? $request->input('location_id') : null);
$department->company_id = ($request->filled('company_id') ? $request->input('company_id') : null);
$department->company_id = ($request->filled('company_id') ? Company::getIdForCurrentUser($request->input('company_id')) : null);
$department->phone = $request->input('phone');
$department->fax = $request->input('fax');
$department->tag_color = $request->input('tag_color');
@@ -54,6 +54,7 @@ class GoogleAuthController extends Controller
Log::debug('Google user '.$socialUser->getEmail().' found in Snipe-IT');
$user->update([
'avatar' => $socialUser->avatar,
'last_login' => \Carbon::now(),
]);
Auth::login($user, true);
+16 -15
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
use App\Helpers\Helper;
use App\Models\Group;
use App\Models\User;
@@ -79,14 +80,12 @@ class GroupsController extends Controller
// create a new group instance
$group = new Group;
$group->name = $request->input('name');
if ($request->filled('permission')) {
$group->permissions = json_encode($request->array('permission'));
} else {
$group->permissions = null;
}
$group->permissions = json_encode($request->input('permission'));
$group->permissions = json_encode(
Helper::selectedPermissionsArray(
config('permissions'),
NormalizePermissionsPayloadAction::run($request->input('permission'))
)
);
$group->created_by = auth()->id();
$group->notes = $request->input('notes');
@@ -167,15 +166,17 @@ class GroupsController extends Controller
public function update(Request $request, Group $group): RedirectResponse
{
$group->name = $request->input('name');
if ($request->filled('permission')) {
$group->permissions = json_encode($request->array('permission'));
} else {
$group->permissions = null;
}
$group->notes = $request->input('notes');
if ($request->has('permission')) {
$group->permissions = json_encode(
Helper::selectedPermissionsArray(
config('permissions'),
NormalizePermissionsPayloadAction::run($request->input('permission'))
)
);
}
if (! config('app.lock_passwords')) {
if ($group->save()) {
@@ -2,8 +2,8 @@
namespace App\Http\Controllers\Kits;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\PredefinedKit;
use App\Models\User;
@@ -23,7 +23,7 @@ class CheckoutKitController extends Controller
{
public $kitService;
use CheckInOutRequest;
use CheckInOutTrait;
public function __construct(PredefinedKitCheckoutService $kitService)
{
@@ -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'));
@@ -43,7 +43,9 @@ class LabelsController extends Controller
'name' => trans('admin/labels/table.example_company'),
'phone' => '1-555-555-5555',
'email' => 'company@example.com',
'logo' => 'label-preview-logo.png',
]);
$exampleAsset->is_label_preview = true;
$exampleAsset->setRelation('assignedTo', new User(['first_name' => 'Luke', 'last_name' => 'Skywalker']));
$exampleAsset->defaultLoc = new Location(['name' => trans('admin/labels/table.example_defaultloc'), 'phone' => '1-555-555-5555']);
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Licenses;
use App\Http\Controllers\Controller;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class BulkLicensesController extends Controller
{
public function destroy(Request $request)
{
$this->authorize('delete', License::class);
$errors = [];
$success_count = 0;
foreach ($request->input('ids', []) as $id) {
$license = License::find($id);
if (is_null($license)) {
$errors[] = trans('admin/licenses/message.does_not_exist');
continue;
}
if (! Gate::allows('delete', $license)) {
$errors[] = trans('general.insufficient_permissions');
continue;
}
if ($license->assigned_seats_count > 0) {
$errors[] = trans('admin/licenses/message.delete.bulk_checkout_warning', ['license_name' => $license->name]);
continue;
}
// Since assigned_seats_count == 0, all seats already have assigned_to and asset_id as null,
// so this update is effectively a no-op. It mirrors the single destroy() and is kept as a
// safety net. Bypassing Eloquent events here is intentional and safe — there is nothing
// assigned to trigger events on. Prior checkout/checkin history is preserved in action_log
// (keyed by LicenseSeat item_type/item_id) and remains accessible even after soft-delete.
DB::table('license_seats')
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$license->licenseseats()->delete();
$license->delete();
$success_count++;
}
if (count($errors) > 0) {
if ($success_count > 0) {
return redirect()->route('licenses.index')
->with('success', trans_choice('admin/licenses/message.delete.partial_success', $success_count, ['count' => $success_count]))
->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('success', trans('admin/licenses/message.delete.bulk_success'));
}
}
@@ -13,6 +13,7 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
@@ -35,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);
}
@@ -69,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 = [
@@ -127,10 +128,45 @@ class LicenseCheckinController extends Controller
* @see LicenseCheckinController::create() method that provides the form view
* @since [v6.1.1]
*
* @return RedirectResponse
*
* @throws AuthorizationException
*/
public function bulkCheckinSelected(Request $request): RedirectResponse
{
$this->authorize('checkin', License::class);
$seatIds = $request->input('ids', []);
if (empty($seatIds)) {
return redirect()->back()->with('warning', trans('admin/licenses/general.bulk.checkin_selected.no_seats_selected'));
}
$seats = LicenseSeat::whereIn('id', $seatIds)
->where(function ($query) {
$query->whereNotNull('assigned_to')->orWhereNotNull('asset_id');
})
->with('license', 'user', 'asset')
->get();
$count = 0;
foreach ($seats as $seat) {
if (! $seat->license || ! Gate::allows('checkin', $seat->license)) {
continue;
}
$target = $seat->user ?? $seat->asset;
$seat->assigned_to = null;
$seat->asset_id = null;
if (! $seat->license->reassignable) {
$seat->unreassignable_seat = true;
}
if ($seat->save()) {
event(new CheckoutableCheckedIn($seat, $target, auth()->user(), null));
$count++;
}
}
return redirect()->back()->with('success', trans_choice('admin/licenses/general.bulk.checkin_selected.success', $count, ['count' => $count]));
}
public function bulkCheckin(Request $request, $licenseId)
{
@@ -7,13 +7,16 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\LicenseCheckoutRequest;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class LicenseCheckoutController extends Controller
@@ -93,25 +96,91 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
}
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId);
$licenseSeat->created_by = auth()->id();
$licenseSeat->notes = $request->input('notes');
if (Setting::getSettings()->full_multiple_companies_support == '1') {
if ($request->filled('asset_id')) {
$fmcsTarget = Asset::find($request->input('asset_id'));
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
'item' => trans('general.license').' "'.$license->name.'"',
'item_company' => $license->company?->name ?? trans('general.unassigned'),
'target' => trans('general.asset').' "'.$fmcsTarget->display_name.'"',
]));
}
} elseif ($request->filled('assigned_to')) {
$fmcsTarget = User::find($request->input('assigned_to'));
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
'item' => trans('general.license').' "'.$license->name.'"',
'item_company' => $license->company?->name ?? trans('general.unassigned'),
'target' => trans('general.user').' "'.$fmcsTarget->username.'"',
]));
}
}
}
$licenseSeat = null;
$checkoutTarget = null;
DB::transaction(function () use ($request, $license, $seatId, &$licenseSeat, &$checkoutTarget): void {
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId, lock: true);
$licenseSeat->created_by = auth()->id();
$licenseSeat->notes = $request->input('notes');
if ($request->filled('asset_id')) {
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
} elseif ($request->filled('assigned_to')) {
$checkoutTarget = $this->checkoutToUser($licenseSeat);
}
});
if ($request->filled('asset_id')) {
session()->put(['checkout_to_type' => 'asset']);
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
$request->request->add(['assigned_asset' => $checkoutTarget->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'asset']);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'asset',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
} elseif ($request->filled('assigned_to')) {
session()->put(['checkout_to_type' => 'user']);
$checkoutTarget = $this->checkoutToUser($licenseSeat);
$request->request->add(['assigned_user' => $checkoutTarget->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'user']);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'user',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
}
if ($checkoutTarget) {
// When sign_in_place is requested and the target is a user, redirect to the
// acceptance/signature page so the user can sign in person.
if ($request->boolean('sign_in_place') && $checkoutTarget instanceof User) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
->where('checkoutable_id', $licenseSeat->id)
->where('assigned_to_id', $checkoutTarget->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($licenseSeat);
$acceptance->assignedTo()->associate($checkoutTarget);
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $license->id,
'sign_in_place_resource_type' => 'Licenses',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/licenses/message.checkout.success'));
}
return Helper::getRedirectOption($request, $license->id, 'Licenses')
->with('success', trans('admin/licenses/message.checkout.success'));
}
@@ -119,9 +188,11 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('Something went wrong handling this checkout.'));
}
protected function findLicenseSeatToCheckout($license, $seatId)
protected function findLicenseSeatToCheckout($license, $seatId, bool $lock = false)
{
$licenseSeat = LicenseSeat::find($seatId) ?? $license->freeSeat();
$licenseSeat = $seatId
? LicenseSeat::where('id', $seatId)->when($lock, fn ($q) => $q->lockForUpdate())->first()
: $license->freeSeat(lock: $lock);
if (! $licenseSeat) {
if ($seatId) {
@@ -150,7 +221,7 @@ class LicenseCheckoutController extends Controller
$licenseSeat->assigned_to = $target->assigned_to;
}
if ($licenseSeat->save()) {
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
return $target;
}
@@ -167,7 +238,7 @@ class LicenseCheckoutController extends Controller
$licenseSeat->assigned_to = request('assigned_to');
if ($licenseSeat->save()) {
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
return $target;
}
@@ -192,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
@@ -207,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) {
@@ -12,6 +12,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -262,7 +263,7 @@ class LicensesController extends Controller
*/
public function show(License $license)
{
$license = License::with('assignedusers')->find($license->id);
$license = License::with('assignedusers')->withCount('freeSeats as free_seats_count')->find($license->id);
$users_count = User::where('autoassign_licenses', '1')->count();
@@ -388,6 +389,8 @@ class LicensesController extends Controller
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($licenses as $license) {
// Add a new row with data
$values = [
@@ -419,7 +422,14 @@ class LicensesController extends Controller
$license->created_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
+34 -12
View File
@@ -89,19 +89,24 @@ class LocationsController extends Controller
$location->fax = request('fax');
$location->tag_color = $request->input('tag_color');
$location->notes = $request->input('notes');
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// Only scope the location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// check if parent is set and has a different company
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
return redirect()->back()->withInput()->withInput()->with('error', 'different company than parent');
}
} else {
$location->company_id = $request->input('company_id');
}
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
if (Setting::getSettings()->full_multiple_companies_support) {
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
if ($parent && $parent->company_id != $location->company_id) {
return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
'parent' => $parent->name,
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
'location_company' => $location->company?->name ?? trans('general.unassigned'),
]));
}
}
if ($request->has('use_cloned_image')) {
$cloned_model_img = Location::select('image')->find($request->input('clone_image_from_id'));
if ($cloned_model_img) {
@@ -171,17 +176,34 @@ class LocationsController extends Controller
$location->tag_color = $request->input('tag_color');
$location->notes = $request->input('notes');
// Only scope the location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// check if there are related objects with different company
if (Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
return redirect()->back()->withInput()->withInput()->with('error', 'error scoped locations');
if ($mismatched = Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
$first = $mismatched[0];
return redirect()->back()->withInput()->with('error', trans('general.error_location_scoped_items', [
'item_type' => trans('general.'.strtolower($first[0])),
'item_name' => $first[2],
'item_company' => $first[5] ?? trans('general.unassigned'),
]));
}
} else {
$location->company_id = $request->input('company_id');
}
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
if (Setting::getSettings()->full_multiple_companies_support) {
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
if ($parent && $parent->company_id != $location->company_id) {
return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
'parent' => $parent->name,
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
'location_company' => $location->company?->name ?? trans('general.unassigned'),
]));
}
}
$location = $request->handleImages($location);
if ($location->save()) {
@@ -277,7 +299,7 @@ class LocationsController extends Controller
->with('assignedAssets', $location->assignedAssets)
->with('accessories', $location->accessories)
->with('assignedAccessories', $location->assignedAccessories)
->with('users', $location->users)
->with('users', $location->users()->with('companies')->get())
->with('location', $location)
->with('consumables', $location->consumables)
->with('components', $location->components)
@@ -297,7 +319,7 @@ class LocationsController extends Controller
->with('assignedAssets', $location->assignedAssets)
->with('accessories', $location->accessories)
->with('assignedAccessories', $location->assignedAccessories)
->with('users', $location->users)
->with('users', $location->users()->with('companies')->get())
->with('location', $location)
->with('consumables', $location->consumables)
->with('components', $location->components)
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\MaintenanceType;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class MaintenanceTypesController extends Controller
{
public function index(): View
{
$this->authorize('index', MaintenanceType::class);
return view('maintenance-types.index');
}
public function create(): View
{
$this->authorize('create', MaintenanceType::class);
return view('maintenance-types.edit')->with('item', new MaintenanceType);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', MaintenanceType::class);
$type = new MaintenanceType;
$type->name = $request->input('name');
$type->created_by = auth()->id();
if ($type->save()) {
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.create.success'));
}
return redirect()->back()->withInput()->withErrors($type->getErrors());
}
public function edit(MaintenanceType $maintenanceType): View
{
$this->authorize('update', $maintenanceType);
return view('maintenance-types.edit')->with('item', $maintenanceType);
}
public function update(Request $request, MaintenanceType $maintenanceType): RedirectResponse
{
$this->authorize('update', $maintenanceType);
$maintenanceType->name = $request->input('name');
if ($maintenanceType->save()) {
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.update.success'));
}
return redirect()->back()->withInput()->withErrors($maintenanceType->getErrors());
}
public function destroy(MaintenanceType $maintenanceType): RedirectResponse
{
$this->authorize('delete', $maintenanceType);
$maintenanceType->delete();
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.delete.success'));
}
}
+105 -30
View File
@@ -2,13 +2,19 @@
namespace App\Http\Controllers;
use App\Enums\ActionType;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\UploadFileRequest;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use Carbon\Carbon;
use App\Models\MaintenanceType;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
/**
* This controller handles all actions related to Asset Maintenance for
@@ -54,6 +60,7 @@ class MaintenancesController extends Controller
return view('maintenances/edit')
->with('maintenanceType', Maintenance::getImprovementOptions())
->with('maintenanceTypes', MaintenanceType::orderBy('name')->get())
->with('asset', $asset)
->with('item', new Maintenance);
}
@@ -72,12 +79,17 @@ class MaintenancesController extends Controller
public function store(ImageUploadRequest $request): RedirectResponse
{
$this->authorize('update', Asset::class);
$this->validateUploadedFiles($request);
$assets = Asset::whereIn('id', $request->input('selected_assets'))->get();
// Loop through the selected assets
foreach ($assets as $asset) {
if (! Company::isCurrentUserHasAccess($asset)) {
continue;
}
$maintenance = new Maintenance;
$maintenance->supplier_id = $request->input('supplier_id');
$maintenance->is_warranty = $request->input('is_warranty');
@@ -88,26 +100,21 @@ class MaintenancesController extends Controller
// Save the asset maintenance data
$maintenance->asset_id = $asset->id;
$maintenance->asset_maintenance_type = $request->input('asset_maintenance_type');
$maintenance->maintenance_type_id = $request->input('maintenance_type_id');
$maintenance->name = $request->input('name');
$maintenance->start_date = $request->input('start_date');
$maintenance->completion_date = $request->input('completion_date');
$maintenance->responsible_party_id = $request->input('responsible_party_id') ?: auth()->id();
$maintenance->created_by = auth()->id();
if (($maintenance->completion_date !== null)
&& ($maintenance->start_date !== '')
&& ($maintenance->start_date !== '0000-00-00')
) {
$startDate = Carbon::parse($maintenance->start_date);
$completionDate = Carbon::parse($maintenance->completion_date);
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$maintenance = $request->handleImages($maintenance);
$request->handleImages($maintenance);
// Was the asset maintenance created?
if (! $maintenance->save()) {
return redirect()->back()->withInput()->withErrors($maintenance->getErrors());
}
$this->storeUploadedFiles($request, $maintenance);
}
return redirect()->route('maintenances.index')
@@ -135,6 +142,7 @@ class MaintenancesController extends Controller
->with('selected_assets', $maintenance->asset->pluck('id')->toArray())
->with('asset_ids', request()->input('asset_ids', []))
->with('maintenanceType', Maintenance::getImprovementOptions())
->with('maintenanceTypes', MaintenanceType::orderBy('name')->get())
->with('item', $maintenance);
}
@@ -156,37 +164,24 @@ class MaintenancesController extends Controller
{
$this->authorize('update', Asset::class);
$this->authorize('update', $maintenance->asset);
$this->validateUploadedFiles($request);
$maintenance->supplier_id = $request->input('supplier_id');
$maintenance->is_warranty = $request->input('is_warranty', 0);
$maintenance->cost = $request->input('cost');
$maintenance->notes = $request->input('notes');
$maintenance->asset_maintenance_type = $request->input('asset_maintenance_type');
$maintenance->maintenance_type_id = $request->input('maintenance_type_id');
$maintenance->name = $request->input('name');
$maintenance->start_date = $request->input('start_date');
$maintenance->completion_date = $request->input('completion_date');
$maintenance->responsible_party_id = $request->input('responsible_party_id');
$maintenance->url = $request->input('url');
// Todo - put this in a getter/setter?
if (($maintenance->completion_date == null)) {
if (($maintenance->asset_maintenance_time !== 0)
|| (! is_null($maintenance->asset_maintenance_time))
) {
$maintenance->asset_maintenance_time = null;
}
}
if (($maintenance->completion_date !== null)
&& ($maintenance->start_date !== '')
&& ($maintenance->start_date !== '0000-00-00')
) {
$startDate = Carbon::parse($maintenance->start_date);
$completionDate = Carbon::parse($maintenance->completion_date);
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$maintenance = $request->handleImages($maintenance);
$request->handleImages($maintenance);
if ($maintenance->save()) {
$this->storeUploadedFiles($request, $maintenance);
return redirect()->route('maintenances.index')
->with('success', trans('admin/maintenances/message.edit.success'));
}
@@ -194,6 +189,86 @@ class MaintenancesController extends Controller
return redirect()->back()->withInput()->withErrors($maintenance->getErrors());
}
/**
* Stores any generic file uploads submitted from the maintenance form.
*/
private function storeUploadedFiles(ImageUploadRequest $request, Maintenance $maintenance): void
{
if (! $request->hasFile('file')) {
return;
}
$objectType = 'maintenances';
$storagePath = self::$map_storage_path[$objectType];
if (! Storage::exists($storagePath)) {
Storage::makeDirectory($storagePath, 775);
}
$uploadFileRequest = app(UploadFileRequest::class);
foreach ((array) $request->file('file') as $file) {
if (! $file) {
continue;
}
$fileName = $uploadFileRequest->handleFile(
$storagePath,
self::$map_file_prefix[$objectType].'-'.$maintenance->id,
$file
);
$maintenance->logUpload($fileName, $request->input('file_notes'));
}
}
/**
* Validate generic file uploads with the shared UploadFileRequest rules.
*/
private function validateUploadedFiles(ImageUploadRequest $request): void
{
if (! $request->hasFile('file')) {
return;
}
$uploadFileRequest = app(UploadFileRequest::class);
Validator::make(
array_merge($request->all(), ['file' => $request->file('file')]),
$uploadFileRequest->rules()
)->validate();
}
/**
* Mark a maintenance record as complete, logging who completed it and when.
*/
public function complete(Request $request, Maintenance $maintenance): RedirectResponse
{
$this->authorize('update', $maintenance->asset);
if ($maintenance->completed_at) {
return redirect()->back()
->with('warning', trans('admin/maintenances/form.already_complete'));
}
$maintenance->completed_at = now();
$maintenance->completed_by = auth()->id();
$maintenance->asset_maintenance_time = (int) $maintenance->created_at->diffInDays(now(), true);
$maintenance->saveQuietly();
$logAction = new Actionlog;
$logAction->item_type = Maintenance::class;
$logAction->item_id = $maintenance->id;
$logAction->target_type = Asset::class;
$logAction->target_id = $maintenance->asset_id;
$logAction->created_by = auth()->id();
$logAction->note = $request->input('note');
$logAction->logaction(ActionType::MaintenanceComplete);
return redirect()->back()
->with('success', trans('admin/maintenances/message.complete.success'));
}
/**
* Delete an asset maintenance
*
+1
View File
@@ -30,6 +30,7 @@ class ModalController extends Controller
'kit-consumable',
'kit-accessory',
'location',
'maintenance-type',
'manufacturer',
'model',
'statuslabel',
+14 -9
View File
@@ -4,13 +4,15 @@ namespace App\Http\Controllers;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Maintenance;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class NotesController extends Controller
{
public function store(Request $request)
public function store(Request $request): RedirectResponse
{
$this->authorize('update', Asset::class);
@@ -19,13 +21,19 @@ class NotesController extends Controller
'note' => 'required|string|max:50000',
'type' => [
'required',
Rule::in(['asset']),
Rule::in(['asset', 'maintenance']),
],
]);
$item = Asset::findOrFail($validated['id']);
$this->authorize('update', $item);
if ($validated['type'] === 'maintenance') {
$item = Maintenance::findOrFail($validated['id']);
$this->authorize('update', $item->asset);
$redirect = redirect()->route('maintenances.show', $validated['id']);
} else {
$item = Asset::findOrFail($validated['id']);
$this->authorize('update', $item);
$redirect = redirect()->route('hardware.show', $validated['id']);
}
$logaction = new Actionlog;
$logaction->item_id = $item->id;
@@ -34,9 +42,6 @@ class NotesController extends Controller
$logaction->created_by = Auth::id();
$logaction->logaction('note added');
return redirect()
->route('hardware.show', $validated['id'])
->withFragment('history')
->with('success', trans('general.note_added'));
return $redirect->withFragment('notes')->with('success', trans('general.note_added'));
}
}
+20 -7
View File
@@ -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');
@@ -211,14 +218,19 @@ class ProfileController extends Controller
*/
public function printInventory(): View
{
$show_users = User::where('id', auth()->user()->id)->get();
$userId = auth()->id();
return view('users/print')
->with('assets', auth()->user()->assets())
->with('licenses', auth()->user()->licenses()->get())
->with('accessories', auth()->user()->accessories()->get())
->with('consumables', auth()->user()->consumables()->get())
->with('users', $show_users)
$show_user = User::withInventoryRelations($userId)->first();
$indirectItemsCount =
$show_user->assets->flatMap->assignedAssets->count()
+ $show_user->assets->flatMap->components->count()
+ $show_user->assets->flatMap->licenses->count()
+ $show_user->assets->flatMap->assignedAccessories->count();
return view('users.print')
->with('users', [$show_user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
@@ -251,6 +263,7 @@ class ProfileController extends Controller
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
{
$filename = basename((string) $filename);
$logentry = Actionlog::where('filename', $filename)->first();
+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');
}
}
+291 -171
View File
@@ -32,11 +32,10 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use League\Csv\EscapeFormula;
use League\Csv\Reader;
use League\Csv\Writer;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -55,6 +54,31 @@ class ReportsController extends Controller
parent::__construct();
}
public function index(): View
{
$this->authorize('reports.view');
$settings = Setting::getSettings();
$audit_alert_count = Asset::DueOrOverdueForAudit($settings)->count();
$checkin_alert_count = Asset::DueOrOverdueForCheckin($settings)->count();
// CheckoutAcceptance has no company_id column; scope through the checkoutable
// relationship so each type's CompanyableTrait global scope is applied.
$pending_acceptance_count = CheckoutAcceptance::pending()
->whereHasMorph('checkoutable', [Asset::class, LicenseSeat::class, Accessory::class, Component::class, Consumable::class])
->count();
$licenses_low_count = License::withCount(['freeSeats as free_seats_count'])
->get()
->filter(fn ($l) => $l->free_seats_count <= 0)
->count();
return view('reports/index', compact(
'audit_alert_count',
'checkin_alert_count',
'pending_acceptance_count',
'licenses_low_count',
));
}
/**
* Returns a view that displays the accessories report.
*
@@ -79,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;
}
@@ -137,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', 'assetstatus', '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;
}
/**
@@ -251,6 +291,7 @@ class ReportsController extends Controller
$response = new StreamedResponse(function () {
Log::debug('Starting streamed response');
Log::debug('CSV escaping is set to: '.config('app.escape_formulas'));
// Open output stream
$handle = fopen('php://output', 'w');
@@ -286,6 +327,8 @@ class ReportsController extends Controller
Log::debug('Walking results: '.$executionTime);
$count = 0;
$formatter = new EscapeFormula('`');
foreach ($actionlogs as $actionlog) {
$count++;
$target_name = '';
@@ -316,7 +359,15 @@ class ReportsController extends Controller
$actionlog->action_source,
$actionlog->log_meta,
];
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($row));
}
}
});
@@ -358,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;
}
@@ -675,7 +733,7 @@ class ReportsController extends Controller
}
$assets = Asset::select('assets.*')->with(
'location', 'assetstatus', 'company', 'defaultLoc', 'assignedTo',
'location', 'status', 'company', 'defaultLoc', 'assignedTo',
'model.category', 'model.manufacturer', 'supplier');
if ($request->filled('by_location_id')) {
@@ -741,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'))) {
@@ -797,6 +854,14 @@ class ReportsController extends Controller
$assets->onlyTrashed();
}
if ($request->input('assignment_status') === 'assigned') {
$assets->whereNotNull('assets.assigned_to');
}
if ($request->input('assignment_status') === 'unassigned') {
$assets->whereNull('assets.assigned_to');
}
$assets->orderBy('assets.id', 'ASC')->chunk(500, function ($assets) use ($handle, $customfields, $request) {
$executionTime = microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'];
@@ -843,7 +908,7 @@ class ReportsController extends Controller
}
if ($request->filled('purchase_date')) {
$row[] = ($asset->purchase_date) ? $asset->purchase_date : '';
$row[] = ($asset->purchase_date) ? Carbon::parse($asset->purchase_date)->format('Y-m-d') : '';
}
if ($request->filled('purchase_cost')) {
@@ -851,7 +916,7 @@ class ReportsController extends Controller
}
if ($request->filled('eol')) {
$row[] = ($asset->asset_eol_date != '') ? $asset->asset_eol_date : '';
$row[] = ($asset->asset_eol_date != '') ? Carbon::parse($asset->asset_eol_date)->format('Y-m-d') : '';
}
if ($request->filled('warranty')) {
@@ -917,7 +982,7 @@ class ReportsController extends Controller
if ($request->filled('user_company')) {
if ($asset->checkedOutToUser()) {
$row[] = ($asset->assignedto->company) ? $asset->assignedto->company->display_name : '';
$row[] = ($asset->assignedto?->company) ? $asset->assignedto?->company?->display_name : '';
} else {
$row[] = ''; // Empty string if unassigned
}
@@ -1022,7 +1087,7 @@ class ReportsController extends Controller
}
if ($request->filled('status')) {
$row[] = ($asset->assetstatus) ? $asset->assetstatus->name.' ('.$asset->present()->statusMeta.')' : '';
$row[] = ($asset->status) ? $asset->status->name.' ('.$asset->present()->statusMeta.')' : '';
}
if ($request->filled('checkout_date')) {
@@ -1070,7 +1135,13 @@ class ReportsController extends Controller
foreach ($customfields as $customfield) {
$column_name = $customfield->db_column_name();
if ($request->filled($customfield->db_column_name())) {
$row[] = $asset->$column_name;
$value = $asset->$column_name;
if (($customfield->field_encrypted == '1') && Gate::allows('assets.view.encrypted_custom_fields')) {
$value = Helper::gracefulDecrypt($customfield, $value);
}
$row[] = $value;
}
}
@@ -1121,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;
}
@@ -1185,6 +1260,9 @@ class ReportsController extends Controller
public function getAssetAcceptanceReport($deleted = false): View
{
$this->authorize('reports.view');
$this->disableDebugbar();
$showDeleted = $deleted == 'deleted';
$query = CheckoutAcceptance::Pending()
@@ -1246,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;
@@ -1280,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 = [
@@ -1312,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 {
+167 -2
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Enums\ActionType;
use App\Helpers\Helper;
use App\Helpers\StorageHelper;
use App\Http\Requests\ImageUploadRequest;
@@ -11,12 +12,14 @@ use App\Http\Requests\StoreLdapSettings;
use App\Http\Requests\StoreLocalizationSettings;
use App\Http\Requests\StoreNotificationSettings;
use App\Http\Requests\StoreSecuritySettings;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CustomField;
use App\Models\Group;
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;
@@ -90,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
@@ -187,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');
@@ -870,6 +882,11 @@ class SettingsController extends Controller
public function downloadFile($filename = null): RedirectResponse|BinaryFileResponse
{
$path = 'app/backups';
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (! config('app.lock_passwords')) {
if (Storage::exists($path.'/'.$filename)) {
@@ -895,6 +912,12 @@ class SettingsController extends Controller
*/
public function deleteFile($filename = null): RedirectResponse
{
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (config('app.allow_backup_delete') == 'true') {
if (! config('app.lock_passwords')) {
@@ -969,6 +992,11 @@ class SettingsController extends Controller
*/
public function postRestore(Request $request, $filename = null): RedirectResponse
{
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (! config('app.lock_passwords')) {
$path = 'app/backups';
@@ -1118,7 +1146,86 @@ class SettingsController extends Controller
*/
public function api(): View
{
return view('settings.api');
$personalAccessTokenCount = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->where('oauth_clients.personal_access_client', true)
->count();
return view('settings.api', [
'personalAccessTokenCount' => $personalAccessTokenCount,
]);
}
/**
* Revoke a personal access token from the admin OAuth settings page.
*/
public function revokePersonalAccessToken(string $token): RedirectResponse
{
$tokenRow = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->where('oauth_access_tokens.id', $token)
->where('oauth_clients.personal_access_client', true)
->select(['oauth_access_tokens.id', 'oauth_access_tokens.user_id'])
->first();
if ($tokenRow === null) {
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('error', trans('admin/settings/message.oauth.token_not_found'));
}
DB::table('oauth_access_tokens')
->where('id', $tokenRow->id)
->update(['revoked' => true]);
$logaction = new Actionlog;
$logaction->item_type = User::class;
$logaction->item_id = $tokenRow->user_id;
$logaction->target_type = User::class;
$logaction->target_id = $tokenRow->user_id;
$logaction->created_by = auth()->id();
// $logaction->note = 'Token ID: ' . $tokenRow->id;
$logaction->logaction(ActionType::TokenRevoked);
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('success', trans('admin/settings/message.oauth.token_revoked'));
}
/**
* Unrevoke a personal access token from the admin OAuth settings page.
*/
public function unrevokePersonalAccessToken(string $token): RedirectResponse
{
$tokenRow = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->where('oauth_access_tokens.id', $token)
->where('oauth_clients.personal_access_client', true)
->select(['oauth_access_tokens.id', 'oauth_access_tokens.user_id'])
->first();
if ($tokenRow === null) {
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('error', trans('admin/settings/message.oauth.token_not_found'));
}
DB::table('oauth_access_tokens')
->where('id', $tokenRow->id)
->update(['revoked' => false]);
$logaction = new Actionlog;
$logaction->item_type = User::class;
$logaction->item_id = $tokenRow->user_id;
$logaction->target_type = User::class;
$logaction->target_id = $tokenRow->user_id;
$logaction->created_by = auth()->id();
// $logaction->note = 'Token ID: ' . $tokenRow->id;
$logaction->logaction(ActionType::TokenUnrevoked);
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('success', trans('admin/settings/message.oauth.token_unrevoked'));
}
/**
@@ -1155,4 +1262,62 @@ class SettingsController extends Controller
{
return view('settings.logins');
}
/**
* Revoke an OAuth client from the admin OAuth settings page.
*/
public function revokeOAuthClient(string $client): RedirectResponse
{
$oauthClient = DB::table('oauth_clients')
->where('id', $client)
->first();
if ($oauthClient === null) {
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('error', trans('admin/settings/message.oauth.client_not_found'));
}
DB::table('oauth_clients')
->where('id', $client)
->update(['revoked' => true]);
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('success', trans('admin/settings/message.oauth.client_revoked'));
}
/**
* Unrevoke an OAuth client from the admin OAuth settings page.
*/
public function unrevokeOAuthClient(string $client): RedirectResponse
{
$oauthClient = DB::table('oauth_clients')
->where('id', $client)
->first();
if ($oauthClient === null) {
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('error', trans('admin/settings/message.oauth.client_not_found'));
}
DB::table('oauth_clients')
->where('id', $client)
->update(['revoked' => false]);
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('success', trans('admin/settings/message.oauth.client_unrevoked'));
}
private function hasInvalidBackupFilename(string $filename): bool
{
if ($filename === '' || $filename === '.' || $filename === '..') {
return true;
}
// Reject path separators in case a crafted value survives route decoding.
return str_contains($filename, '/') || str_contains($filename, '\\');
}
}
+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');
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StorageProxyController extends Controller
{
/**
* Proxy files from the "public" storage disk through the application.
*
* When PUBLIC_S3_PROXY is enabled, this serves files that would normally
* be accessed directly from S3 (images, logos, avatars, etc.), allowing
* a fully private S3 bucket setup.
*/
public function show(string $path): Response|StreamedResponse
{
if ($this->hasPathTraversalSegments($path)) {
abort(404);
}
$disk = Storage::disk('public');
// The S3 adapter includes the disk's root prefix in generated URLs,
// but Flysystem also prepends it internally on every operation.
// Strip it here to avoid double-prefixing.
$root = trim(config('filesystems.disks.public.root', ''), '/');
if ($root !== '' && str_starts_with($path, $root.'/')) {
$path = substr($path, strlen($root) + 1);
}
if (! $disk->exists($path)) {
abort(404);
}
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
$lastModified = $disk->lastModified($path);
$etag = md5($path.$lastModified);
$size = $disk->size($path);
if ($this->isNotModified($etag, $lastModified)) {
return response('', 304)
->header('ETag', '"'.$etag.'"')
->header('Cache-Control', 'public, max-age=86400');
}
return new StreamedResponse(function () use ($disk, $path) {
$stream = $disk->readStream($path);
fpassthru($stream);
if (is_resource($stream)) {
fclose($stream);
}
}, 200, [
'Content-Type' => $mimeType,
'Content-Length' => $size,
'ETag' => '"'.$etag.'"',
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified).' GMT',
'Cache-Control' => 'public, max-age=86400',
]);
}
private function isNotModified(string $etag, int $lastModified): bool
{
$requestEtag = request()->header('If-None-Match');
if ($requestEtag && $requestEtag === '"'.$etag.'"') {
return true;
}
$ifModifiedSince = request()->header('If-Modified-Since');
if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) {
return true;
}
return false;
}
private function hasPathTraversalSegments(string $path): bool
{
$normalizedPath = str_replace('\\', '/', $path);
return str_contains($normalizedPath, "\0")
|| str_starts_with($normalizedPath, '/')
|| str_contains($normalizedPath, '../')
|| str_contains($normalizedPath, '/..')
|| str_ends_with($normalizedPath, '/..')
|| $normalizedPath === '..';
}
}
@@ -37,7 +37,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('update', $object);
$this->authorize('files', $object);
if (! $object) {
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_object'));
@@ -85,7 +85,7 @@ class UploadedFilesController extends Controller
{
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('view', $object);
$this->authorize('files', $object);
if (! $object) {
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_object'));
@@ -96,19 +96,21 @@ class UploadedFilesController extends Controller
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_id'));
}
if (! Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
if (! Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.file_not_found'));
}
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);
return StorageHelper::downloader(self::$map_storage_path[$object_type].$log->filename);
}
@@ -129,7 +131,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('update', $object);
$this->authorize('files', $object);
if (! $object) {
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_object'));
@@ -141,8 +143,8 @@ class UploadedFilesController extends Controller
if ($log) {
// Check the file actually exists, and delete it
if (Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
Storage::delete(self::$map_storage_path[$object_type].'/'.$log->filename);
if (Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
Storage::delete(self::$map_storage_path[$object_type].$log->filename);
}
// Delete the record of the file
if ($log->logUploadDelete($object, $log->filename)) {
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\ConsumableAssignment;
use App\Models\Group;
use App\Models\License;
@@ -168,16 +169,21 @@ class BulkUsersController extends Controller
$this->conditionallyAddItem('location_id')
->conditionallyAddItem('department_id')
->conditionallyAddItem('company_id')
->conditionallyAddItem('locale')
->conditionallyAddItem('remote')
->conditionallyAddItem('ldap_import')
->conditionallyAddItem('activated')
->conditionallyAddItem('display_name')
->conditionallyAddItem('start_date')
->conditionallyAddItem('end_date')
->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)) {
@@ -202,7 +208,7 @@ class BulkUsersController extends Controller
$this->update_array['manager_id'] = null;
}
if ($request->input('null_company_id') == '1') {
if ($request->input('null_company_ids') == '1') {
$this->update_array['company_id'] = null;
}
@@ -222,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');
}
@@ -235,11 +281,50 @@ class BulkUsersController extends Controller
->update(['location_id' => $this->update_array['location_id']]);
}
// Only sync groups if groups were selected
if ($request->filled('groups')) {
// Handle company pivot sync separately from the mass update.
// company_ids[] comes from the multi-select; null_company_ids clears all memberships.
$bulkCompanyIds = array_filter(array_map('intval', (array) $request->input('company_ids', [])));
$clearCompanies = $request->input('null_company_ids') == '1';
if ($bulkCompanyIds || $clearCompanies) {
$allowedIds = Company::getIdsForCurrentUser($bulkCompanyIds);
// Also update the scalar company_id column for display/backward compat.
$scalarCompanyId = $allowedIds[0] ?? null;
User::whereIn('id', $user_raw_array)->where('id', '!=', auth()->id())
->update(['company_id' => $scalarCompanyId]);
foreach ($users as $user) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
if ($clearCompanies && ! auth()->user()->isSuperUser() && Company::isFullMultipleCompanySupportEnabled()) {
// Non-superusers can only detach companies they belong to; sync([]) would
// also wipe memberships for companies outside their scope.
$user->companies()->detach(Company::getIdsForCurrentUser(
$user->companies()->pluck('companies.id')->toArray()
));
} else {
$user->companies()->sync($allowedIds);
}
}
}
// Fields that require canEditAuthFields (non-admins cannot touch admins/superusers,
// admins cannot touch superusers) must be applied per-user, not via mass update.
foreach ($users as $user) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$authFieldUpdate = [];
if ($request->filled('activated')) {
$authFieldUpdate['activated'] = $request->input('activated');
}
if ($request->filled('ldap_import')) {
$authFieldUpdate['ldap_import'] = $request->input('ldap_import');
}
if ($request->filled('email')) {
$authFieldUpdate['email'] = $request->input('email');
} elseif ($request->input('null_email') == '1') {
$authFieldUpdate['email'] = null;
}
if (! empty($authFieldUpdate)) {
$user->update($authFieldUpdate);
}
if ($request->filled('groups') && auth()->user()->isSuperUser()) {
$user->groups()->sync($request->input('groups'));
}
}
@@ -310,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);
@@ -362,7 +472,9 @@ class BulkUsersController extends Controller
$logAction->target_id = $item->assigned_to;
$logAction->target_type = User::class;
$logAction->created_by = auth()->id();
$logAction->note = 'Bulk checkin items';
$logAction->action_date = now();
$logAction->created_at = now();
$logAction->note = 'Bulk checkin items on user bulk edit/delete';
$logAction->logaction('checkin from');
}
}
@@ -376,7 +488,9 @@ class BulkUsersController extends Controller
$logAction->target_id = $accessoryUserRow->assigned_to;
$logAction->target_type = User::class;
$logAction->created_by = auth()->id();
$logAction->note = 'Bulk checkin items';
$logAction->created_at = now();
$logAction->action_date = now();
$logAction->note = 'Bulk checkin accessory on user bulk edit/delete';
$logAction->logaction('checkin from');
}
}
@@ -394,7 +508,7 @@ class BulkUsersController extends Controller
*/
public function merge(Request $request)
{
$this->authorize('update', User::class);
$this->authorize('delete', User::class);
if (config('app.lock_passwords')) {
return redirect()->route('users.index')->with('error', trans('general.feature_disabled'));
@@ -412,9 +526,17 @@ 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) {
if (! auth()->user()->can('canEditAuthFields', $user_to_merge) || ! auth()->user()->can('editableOnDemo')) {
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
}
foreach ($user_to_merge->assets as $asset) {
Log::debug('Updating asset: '.$asset->asset_tag.' to '.$merge_into_user->id);
$asset->assigned_to = $request->input('merge_into_id');
@@ -457,6 +579,12 @@ class BulkUsersController extends Controller
$managedLocation->save();
}
// Carry over company pivot memberships from the merged user into the target.
$mergedCompanyIds = $user_to_merge->companies()->pluck('companies.id')->toArray();
if (! empty($mergedCompanyIds)) {
$merge_into_user->companies()->syncWithoutDetaching($mergedCompanyIds);
}
$user_to_merge->delete();
event(new UserMerged($user_to_merge, $merge_into_user, $admin));
+183 -115
View File
@@ -2,15 +2,22 @@
namespace App\Http\Controllers\Users;
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
use App\Actions\Permissions\PreserveUnauthorizedPrivilegedPermissionsAction;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteUserRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\SaveUserRequest;
use App\Mail\UnacceptedAssetReminderMail;
use App\Models\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;
@@ -20,7 +27,9 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -97,6 +106,8 @@ class UsersController extends Controller
public function store(SaveUserRequest $request)
{
$this->authorize('create', User::class);
$authenticatedUser = auth()->user();
$user = new User;
// Username, email, and password need to be handled specially because the need to respect config values on an edit.
$user->email = trim($request->input('email'));
@@ -115,7 +126,7 @@ class UsersController extends Controller
$user->mobile = $request->input('mobile');
$user->location_id = $request->input('location_id', null);
$user->department_id = $request->input('department_id', null);
$user->company_id = Company::getIdForUser($request->input('company_id', null));
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
$user->manager_id = $request->input('manager_id', null);
$user->notes = $request->input('notes');
$user->address = $request->input('address', null);
@@ -130,26 +141,10 @@ class UsersController extends Controller
$user->end_date = $request->input('end_date', null);
$user->autoassign_licenses = $request->input('autoassign_licenses', 0);
// Strip out the superuser permission if the user isn't a superadmin
$permissions_array = $request->input('permission');
// Strip out the individual superuser permission if the API user isn't a superadmin
if (! auth()->user()->isSuperUser()) {
if ((is_array($permissions_array)) && (array_key_exists('superuser', $permissions_array))) {
unset($permissions_array['superuser']);
}
}
// Strip out the individual admin permission if the API user isn't an admin
if (! auth()->user()->isAdmin()) {
if ((is_array($permissions_array)) && (array_key_exists('admin', $permissions_array))) {
unset($permissions_array['admin']);
}
}
$user->permissions = json_encode($permissions_array);
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
));
// we have to invoke the form request here to handle image uploads
app(ImageUploadRequest::class)->handleImages($user, 600, 'avatar', 'avatars', 'avatar');
@@ -161,6 +156,7 @@ class UsersController extends Controller
}
if ($user->save()) {
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
if (($user->activated == '1') && ($user->email != '') && ($request->input('send_welcome') == '1')) {
@@ -172,12 +168,8 @@ class UsersController extends Controller
}
if ($request->filled('groups')) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$user->groups()->sync($request->input('groups'));
}
} else {
$user->groups()->sync([]);
if (auth()->user()->isSuperUser() && auth()->user()->can('editableOnDemo')) {
$user->groups()->sync($request->input('groups'));
}
return Helper::getRedirectOption($request, $user->id, 'Users')
@@ -255,6 +247,8 @@ class UsersController extends Controller
{
$this->authorize('update', $user);
$authenticatedUser = auth()->user();
// This is a janky hack to prevent people from changing admin demo user data on the public demo.
// The $ids 1 and 2 are special since they are seeded as superadmins in the demo seeder.
// Thanks, jerks. You are why we can't have nice things. - snipe
@@ -271,21 +265,7 @@ class UsersController extends Controller
$this->authorize('update', $user);
// Figure out of this user was an admin before this edit
$orig_permissions_array = $user->decodePermissions();
$orig_superuser = '0';
$orig_admin = '0';
if (is_array($orig_permissions_array)) {
if (array_key_exists('superuser', $orig_permissions_array)) {
$orig_superuser = $orig_permissions_array['superuser'];
}
}
if (is_array($orig_permissions_array)) {
if (array_key_exists('admin', $orig_permissions_array)) {
$orig_admin = $orig_permissions_array['admin'];
}
}
$orig_permissions_array = NormalizePermissionsPayloadAction::run($user->decodePermissions());
// Update the user fields
@@ -299,7 +279,7 @@ class UsersController extends Controller
$user->phone = $request->input('phone');
$user->mobile = $request->input('mobile');
$user->location_id = $request->input('location_id', null);
$user->company_id = Company::getIdForUser($request->input('company_id', null));
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
$user->manager_id = $request->input('manager_id', null);
$user->notes = $request->input('notes');
$user->department_id = $request->input('department_id', null);
@@ -335,21 +315,15 @@ class UsersController extends Controller
$user->password = bcrypt($request->input('password'));
}
$permissions_array = $request->input('permission');
// Strip out the superuser permission if the user isn't a superadmin
if (! auth()->user()->isSuperUser()) {
unset($permissions_array['superuser']);
$permissions_array['superuser'] = $orig_superuser;
if ($request->has('permission')) {
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
targetUser: $user,
));
}
if ((! auth()->user()->isSuperUser()) && (! auth()->user()->isAdmin())) {
unset($permissions_array['admin']);
$permissions_array['admin'] = $orig_admin;
}
$user->permissions = json_encode($permissions_array);
// Only save groups if the user is a superuser
if (auth()->user()->isSuperUser()) {
$user->groups()->sync($request->input('groups'));
@@ -366,6 +340,8 @@ class UsersController extends Controller
session()->put(['redirect_option' => $request->input('redirect_option')]);
if ($user->save()) {
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
// Redirect to the user page
return Helper::getRedirectOption($request, $user->id, 'Users')
->with('success', trans('admin/users/message.success.update'));
@@ -473,6 +449,7 @@ class UsersController extends Controller
'accessories',
'licenses',
'userloc',
'groups',
])
->withTrashed()
->find($user->id);
@@ -483,6 +460,7 @@ class UsersController extends Controller
return view('users/view', [
'user' => $user,
'settings' => Setting::getSettings(),
'effectivePermissionsBySection' => $user->getEffectivePermissionsBySection(),
]);
}
@@ -508,7 +486,7 @@ class UsersController extends Controller
$permissions = $request->input('permissions', []);
app('request')->request->set('permissions', $permissions);
$user_to_clone = User::with('userloc')->withTrashed()->find($user->id);
$user_to_clone = User::with('userloc', 'companies')->withTrashed()->find($user->id);
// Make sure they can view this particular user
$this->authorize('view', $user_to_clone);
@@ -565,52 +543,76 @@ class UsersController extends Controller
// Open output stream
$handle = fopen('php://output', 'w');
$headers = [
// strtolower to prevent Excel from trying to open it as a SYLK file
strtolower(trans('general.id')),
trans('admin/companies/table.title'),
trans('admin/users/table.title'),
trans('general.employee_number'),
trans('admin/users/table.first_name'),
trans('admin/users/table.last_name'),
trans('admin/users/table.name'),
trans('admin/users/table.display_name'),
trans('admin/users/table.username'),
trans('admin/users/table.email'),
trans('admin/users/table.phone'),
trans('admin/users/table.mobile'),
trans('general.website'),
trans('general.address'),
trans('general.city'),
trans('general.state'),
trans('general.country'),
trans('general.zip'),
trans('admin/users/table.manager'),
trans('admin/users/table.location'),
trans('general.department'),
trans('general.assets'),
trans('general.licenses'),
trans('general.accessories'),
trans('general.consumables'),
trans('general.groups'),
trans('general.permissions'),
trans('general.notes'),
trans('admin/users/table.activated'),
trans('general.created_at'),
trans('general.importer.vip'),
trans('admin/users/general.remote'),
trans('general.language'),
trans('general.autoassign_licenses'),
trans('general.ldap_sync'),
trans('admin/users/general.two_factor_enrolled'),
trans('admin/users/general.two_factor_active'),
trans('admin/users/table.managed_users'),
trans('admin/users/table.managed_locations'),
trans('admin/users/general.department_manager'),
trans('general.created_by'),
trans('general.updated_at'),
trans('general.start_date'),
trans('general.end_date'),
trans('admin/users/table.last_login'),
trans('admin/licenses/table.deleted_at'),
];
fputcsv($handle, $headers);
$users = User::with(
'assets',
'accessories',
'consumables',
'department',
'department.manager',
'licenses',
'manager',
'groups',
'userloc',
'company'
)->orderBy('created_at', 'DESC')
'companies',
'createdBy'
)->withCount(['managesUsers as manages_users_count', 'managedLocations as manages_locations_count'])
->orderBy('created_at', 'DESC')
->chunk(500, function ($users) use ($handle) {
$headers = [
// strtolower to prevent Excel from trying to open it as a SYLK file
strtolower(trans('general.id')),
trans('admin/companies/table.title'),
trans('admin/users/table.title'),
trans('general.employee_number'),
trans('admin/users/table.first_name'),
trans('admin/users/table.last_name'),
trans('admin/users/table.name'),
trans('admin/users/table.username'),
trans('admin/users/table.email'),
trans('admin/users/table.manager'),
trans('admin/users/table.location'),
trans('general.department'),
trans('general.assets'),
trans('general.licenses'),
trans('general.accessories'),
trans('general.consumables'),
trans('general.groups'),
trans('general.permissions'),
trans('general.notes'),
trans('admin/users/table.activated'),
trans('general.created_at'),
];
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($users as $user) {
$user_groups = '';
foreach ($user->groups as $user_group) {
$user_groups .= $user_group->name.', ';
}
$permissionstring = '';
if ($user->isSuperUser()) {
@@ -624,14 +626,23 @@ class UsersController extends Controller
// Add a new row with data
$values = [
$user->id,
($user->company) ? $user->company->name : '',
$user->companies->pluck('name')->implode('|'),
$user->jobtitle,
$user->employee_num,
$user->first_name,
$user->last_name,
$user->display_name,
$user->getFullNameAttribute(),
$user->getRawOriginal('display_name'),
$user->username,
$user->email,
$user->phone,
$user->mobile,
$user->website,
$user->address,
$user->city,
$user->state,
$user->country,
$user->zip,
($user->manager) ? $user->manager->display_name : '',
($user->userloc) ? $user->userloc->name : '',
($user->department) ? $user->department->name : '',
@@ -639,14 +650,37 @@ class UsersController extends Controller
$user->licenses->count(),
$user->accessories->count(),
$user->consumables->count(),
$user_groups,
$user->groups->pluck('name')->implode(', '),
$permissionstring,
$user->notes,
($user->activated == '1') ? trans('general.yes') : trans('general.no'),
$user->created_at,
($user->vip == '1') ? trans('general.yes') : trans('general.no'),
($user->remote == '1') ? trans('general.yes') : trans('general.no'),
$user->locale,
($user->autoassign_licenses == '1') ? trans('general.yes') : trans('general.no'),
($user->ldap_import == '1') ? trans('general.yes') : trans('general.no'),
($user->two_factor_active_and_enrolled()) ? trans('general.yes') : trans('general.no'),
($user->two_factor_active()) ? trans('general.yes') : trans('general.no'),
$user->manages_users_count,
$user->manages_locations_count,
($user->department && $user->department->manager) ? $user->department->manager->display_name : '',
($user->createdBy) ? $user->createdBy->display_name : '',
$user->updated_at,
$user->start_date,
$user->end_date,
$user->last_login,
$user->deleted_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
@@ -671,32 +705,24 @@ class UsersController extends Controller
{
$this->authorize('view', User::class);
$user = User::where('id', $id)
->with([
'assets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'accessories.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'accessories.category',
'accessories.manufacturer',
'consumables.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'consumables.category',
'consumables.manufacturer',
'licenses.category',
])
->withTrashed()
->first();
$actor = auth()->user();
$canViewLicenses = $actor->can('view', License::class);
$canViewAccessories = $actor->can('view', Accessory::class);
$canViewConsumables = $actor->can('view', Consumable::class);
$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);
return view('users.print')
->with('users', [$user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
@@ -737,6 +763,48 @@ class UsersController extends Controller
}
/**
* Resend pending acceptance reminder email for a specific user.
*/
public function resendAcceptanceReminder(User $user): RedirectResponse
{
$this->authorize('view', $user);
if (empty($user->email)) {
return redirect()->back()->with('error', trans('admin/users/message.user_has_no_email'));
}
if ($user->activated == '0') {
return redirect()->back()->with('error', trans('admin/users/message.not_activated'));
}
$pendingItems = $user->getAssignedItemsWithPendingAcceptance();
if ($pendingItems->isEmpty()) {
return redirect()->back()->with('warning', trans('admin/users/message.error.no_pending_acceptances'));
}
$firstAcceptance = CheckoutAcceptance::query()
->forUser($user)
->pending()
->with('assignedTo')
->first();
if (! $firstAcceptance) {
return redirect()->back()->with('warning', trans('admin/users/message.error.no_pending_acceptances'));
}
$mailable = new UnacceptedAssetReminderMail($firstAcceptance, $pendingItems->count());
if (! empty($user->locale)) {
$mailable->locale($user->locale);
}
Mail::to($user->email)->send($mailable);
return redirect()->back()->with('success', trans_choice('admin/users/message.success.acceptance_reminder_sent', $pendingItems->count(), ['count' => $pendingItems->count()]));
}
/**
* Send individual password reset email
*
+25 -5
View File
@@ -19,6 +19,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* This controller handles all actions related to the ability for users
@@ -120,6 +121,7 @@ class ViewAssetsController extends Controller
'consumables',
'accessories',
'licenses',
'companies',
])->find($selectedUserId);
// If the user to view couldn't be found (shouldn't happen with proper logic), redirect with error
@@ -151,7 +153,7 @@ class ViewAssetsController extends Controller
'requests',
'assets' => function ($q) {
$q->where('requestable', 1)
->whereHas('assetstatus', fn ($s) => $s->where('archived', 0)
->whereHas('status', fn ($s) => $s->where('archived', 0)
->where(fn ($s) => $s->where('deployable', 1)->orWhere('pending', 1)
)
);
@@ -199,21 +201,39 @@ class ViewAssetsController extends Controller
$settings = Setting::getSettings();
if (($item_request = $item->isRequestedBy($user)) || $cancel_by_admin) {
$item->cancelRequest($requestingUser);
$is_admin = $user->isSuperUser() || $user->isAdmin();
if ($cancel_by_admin && ! $is_admin) {
return redirect()->back()->with('error', trans('general.insufficient_permissions'));
}
if (($item_request = $item->isRequestedBy($user)) || ($is_admin && $cancel_by_admin)) {
$item->cancelRequest($is_admin && $cancel_by_admin ? $requestingUser : null);
$data['item_quantity'] = ($item_request) ? $item_request->qty : 1;
$logaction->logaction(ActionType::RequestCanceled);
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$settings->notify(new RequestAssetCancelation($data));
try {
$settings->notify((new RequestAssetCancelation($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send request cancellation notification: '.$e->getMessage());
}
}
return redirect()->back()->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
} 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');
$settings->notify(new RequestAssetNotification($data));
try {
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send asset request notification: '.$e->getMessage());
}
}
return redirect()->route('requestable-assets')->with('success')->with('success', trans('admin/hardware/message.requests.success'));
+4 -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;
@@ -11,11 +10,13 @@ use App\Http\Middleware\CheckLocale;
use App\Http\Middleware\CheckPermissions;
use App\Http\Middleware\CheckUserIsActivated;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\LogAuthedUserHeader;
use App\Http\Middleware\NoSessionStore;
use App\Http\Middleware\PreventBackHistory;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SetAPIResponseHeaders;
use App\Http\Middleware\SetPaginationDefaults;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\VerifyCsrfToken;
@@ -73,7 +74,6 @@ class Kernel extends HttpKernel
CheckUserIsActivated::class,
CheckForTwoFactor::class,
CreateFreshApiToken::class,
AssetCountForSidebar::class,
CheckColorSettings::class,
AuthenticateSession::class,
SubstituteBindings::class,
@@ -82,6 +82,8 @@ class Kernel extends HttpKernel
'api' => [
'auth:api',
CheckLocale::class,
LogAuthedUserHeader::class,
SetPaginationDefaults::class,
SubstituteBindings::class,
],
@@ -1,119 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Models\Asset;
use App\Models\Setting;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class AssetCountForSidebar
{
/**
* Handle an incoming request.
*
* @param Request $request
* @return mixed
*/
public function handle($request, Closure $next)
{
/**
* This needs to be set for the /setup process, since the tables might not exist yet
*/
$total_assets = 0;
$total_due_for_checkin = 0;
$total_overdue_for_checkin = 0;
$total_due_for_audit = 0;
$total_overdue_for_audit = 0;
try {
$settings = Setting::getSettings();
view()->share('settings', $settings);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_assets = Asset::AssetsForShow()->count();
view()->share('total_assets', $total_assets);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_rtd_sidebar = Asset::RTD()->count();
view()->share('total_rtd_sidebar', $total_rtd_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_deployed_sidebar = Asset::Deployed()->count();
view()->share('total_deployed_sidebar', $total_deployed_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_archived_sidebar = Asset::Archived()->count();
view()->share('total_archived_sidebar', $total_archived_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_pending_sidebar = Asset::Pending()->count();
view()->share('total_pending_sidebar', $total_pending_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_undeployable_sidebar = Asset::Undeployable()->count();
view()->share('total_undeployable_sidebar', $total_undeployable_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_byod_sidebar = Asset::where('byod', '=', '1')->count();
view()->share('total_byod_sidebar', $total_byod_sidebar);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_due_for_audit = Asset::DueForAudit($settings)->count();
view()->share('total_due_for_audit', $total_due_for_audit);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_overdue_for_audit = Asset::OverdueForAudit()->count();
view()->share('total_overdue_for_audit', $total_overdue_for_audit);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_due_for_checkin = Asset::DueForCheckin($settings)->count();
view()->share('total_due_for_checkin', $total_due_for_checkin);
} catch (\Exception $e) {
Log::debug($e);
}
try {
$total_overdue_for_checkin = Asset::OverdueForCheckin()->count();
view()->share('total_overdue_for_checkin', $total_overdue_for_checkin);
} catch (\Exception $e) {
Log::debug($e);
}
view()->share('total_due_and_overdue_for_checkin', ($total_due_for_checkin + $total_overdue_for_checkin));
view()->share('total_due_and_overdue_for_audit', ($total_due_for_audit + $total_overdue_for_audit));
return $next($request);
}
}
@@ -46,6 +46,7 @@ class CheckForTwoFactor
return $next($request);
}
redirect()->setIntendedUrl(url()->full()); // save the 'current' URL so we can send the user back to it?
// Otherwise make sure they're enrolled and show them the 2FA code screen
if ((auth()->user()->two_factor_secret != '') && (auth()->user()->two_factor_enrolled == '1')) {
return redirect()->route('two-factor')->with('info', trans('auth/message.two_factor.enter_two_factor_code'));
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class LogAuthedUserHeader
{
/**
* Handle an incoming request.
*
* @param Request $request
* @return mixed
*/
public function handle($request, Closure $next)
{
$response = $next($request);
if ((config('app.authorized_user_header') === true) && ($request->bearerToken() != '')) {
$response->headers->set('X-API-User-ID', auth()?->id());
$response->headers->set('X-API-Token-Name', $request->user()?->token()?->name);
$response->headers->set('X-API-Token-ID', $request->user()?->token()?->id);
}
return $response;
}
}

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