Compare commits

..

45 Commits

Author SHA1 Message Date
snipe f697ef1d03 Pint 2026-05-09 12:20:45 +01:00
snipe 256003b675 Added password reset prompt 2026-05-09 12:20:40 +01:00
snipe 464db7f473 Last one (I hope) 2026-05-08 15:59:37 +01:00
snipe a56426e6f4 And still more 2026-05-08 15:59:00 +01:00
snipe 19e58a8640 Still more localization 2026-05-08 15:56:03 +01:00
snipe d83b64ff32 Added tests 2026-05-08 15:55:48 +01:00
snipe e839d989ec Still more localizations 2026-05-08 15:53:01 +01:00
snipe b8d2be6c3a Added test 2026-05-08 15:52:45 +01:00
snipe b264e07327 More localizations 2026-05-08 15:52:05 +01:00
snipe 25a08faa6d Updated readme 2026-05-08 15:51:45 +01:00
snipe 926afa6c28 Added throttle 2026-05-08 15:43:26 +01:00
snipe e3a042f334 More translations 2026-05-08 15:39:23 +01:00
snipe 082ebeb27f Localize prompts and tools 2026-05-08 15:39:09 +01:00
snipe aed11dfce7 Added readme 2026-05-08 15:18:54 +01:00
snipe 4090e05536 Pint 2026-05-08 15:18:46 +01:00
snipe 49818175cd Split name into two pieces 2026-05-08 15:17:55 +01:00
snipe ef4b2349eb Added common prompts 2026-05-08 15:14:27 +01:00
snipe 926f7dd5f7 Added profile update tool 2026-05-08 15:03:49 +01:00
snipe 8ccc705473 Add a tool to update your own profile 2026-05-08 14:58:08 +01:00
snipe c75d0effe2 Pint :( 2026-05-08 13:10:06 +01:00
snipe 96a3a11f00 This doesn’t actually work yet 2026-05-08 13:09:54 +01:00
snipe 9c97a06c7e Additional tools 2026-05-08 11:45:30 +01:00
snipe 2542221fc9 Added tests 2026-05-08 11:45:16 +01:00
snipe 664a1906c1 Dept tooling 2026-05-08 10:59:06 +01:00
snipe 08b2d0c85d Licenses MCP stuff 2026-05-08 10:37:25 +01:00
snipe dc9f0104f6 Gate checks and accessory scoping 2026-05-07 22:40:23 +01:00
snipe 6b2f2d68b7 Add/delete/checkout/checkin/edit MCP tools for Components 2026-05-07 17:45:29 +01:00
snipe 9aa5ba5cd0 MCP for accessories management 2026-05-07 17:38:03 +01:00
snipe b74e79b814 Added user create, show, list, delete 2026-05-07 17:27:20 +01:00
snipe 7636c2436c TEMPORARILY remove api auth from MCP routes - this is breaking the inspector for me 2026-05-07 16:36:39 +01:00
snipe 0eec6e3688 Fixed tests 2026-05-07 16:36:07 +01:00
snipe d961714358 Updated response 2026-05-07 16:34:38 +01:00
snipe 51bdc3b020 Added audit, delete and update tools 2026-05-07 16:23:34 +01:00
snipe 6a47b4e6a7 More tests 2026-05-07 16:23:08 +01:00
snipe 656dae04a7 Added views 2026-05-07 16:09:21 +01:00
snipe 2f3df9a085 Allow lookup by serial number 2026-05-07 16:01:46 +01:00
snipe 0514901cbc Updated docs for laravel 12 2026-05-07 16:01:27 +01:00
snipe cc0169d2f7 Use auth:api on routes 2026-05-07 16:01:13 +01:00
snipe 490ce6fa5d Added passport oauth for mcp 2026-05-07 16:00:46 +01:00
snipe b731ec6dd6 Added oauth routes to MCP 2026-05-07 15:58:36 +01:00
snipe 91bd2064fd Added tests 2026-05-07 15:54:37 +01:00
snipe deb56f250f Added routes file 2026-05-07 15:54:30 +01:00
snipe 7d57ce4679 Added basic asset tools 2026-05-07 15:54:23 +01:00
snipe 84fea96949 Added AssetBuilder to sequester scopes better
This isn’t fully baked yet - it would touch way too much main code to flip it over just yet
2026-05-07 15:41:01 +01:00
snipe eada5f503c Install laravel MCP 2026-05-07 14:41:02 +01:00
1774 changed files with 29190 additions and 27864 deletions
-9
View File
@@ -4271,15 +4271,6 @@
"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_CONNECTION=sync
QUEUE_DRIVER=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+1 -1
View File
@@ -120,7 +120,7 @@ ENABLE_HSTS=false
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
+1 -1
View File
@@ -72,7 +72,7 @@ CORS_ALLOWED_ORIGINS="*"
# --------------------------------------------
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
# --------------------------------------------
# OPTIONAL: LOGIN THROTTLING
+2 -3
View File
@@ -133,7 +133,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=true
ENABLE_CSP=false
ADDITIONAL_CSP_URLS=null
CORS_ALLOWED_ORIGINS=null
ENABLE_HSTS=false
@@ -142,7 +142,7 @@ ENABLE_HSTS=false
# OPTIONAL: CACHE SETTINGS
# --------------------------------------------
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
CACHE_PREFIX=snipeit
# --------------------------------------------
@@ -210,7 +210,6 @@ 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
# --------------------------------------------
+4 -2
View File
@@ -1,6 +1,7 @@
# GitHub Copilot Custom Instructions for Snipe-IT
These instructions guide Copilot to generate code that aligns with modern Laravel 11 standards, PHP 8.2/8.4 features, software engineering principles, and industry best practices to improve software quality, maintainability, and security.
These instructions guide Copilot to generate code that aligns with modern Laravel 12 standards, PHP 8.2/8.4 features,
software engineering principles, and industry best practices to improve software quality, maintainability, and security.
## ✅ General Coding Standards
@@ -22,7 +23,7 @@ These instructions guide Copilot to generate code that aligns with modern Larave
- Adopt **final classes** where extension is not intended.
- Use **Named Arguments** for improved clarity when calling functions with multiple parameters.
## ✅ Laravel 11 Project Structure & Conventions
## ✅ Laravel 12 Project Structure & Conventions
- Follow the official Laravel project structure:
- `app/Http/Controllers` - Controllers
@@ -32,6 +33,7 @@ These instructions guide Copilot to generate code that aligns with modern Larave
- `app/Enums` - Enums
- `app/Actions` - Single-responsibility action classes
- `app/Policies` - Authorization logic
- `app/Models/Builders` - Query scoping logic
- Controllers must:
- Use dependency injection.
-69
View File
@@ -1,69 +0,0 @@
# 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
+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.5",
"php_max_wontwork": "8.6.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.4",
"php_max_wontwork": "8.5.0",
"current_snipeit_version": "8.0"
}
-110
View File
@@ -1,110 +0,0 @@
# 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/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") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") |
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
-1
View File
@@ -56,7 +56,6 @@ COPY --from=mlocati/php-extension-installer:2.1.15 /usr/bin/install-php-extensio
RUN set -eux; \
install-php-extensions \
bcmath \
exif \
gd \
ldap \
mysqli \
+1 -2
View File
@@ -7,7 +7,7 @@
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 12](http://laravel.com).
It is built on [Laravel 11](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,7 +98,6 @@ 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.
@@ -13,13 +13,8 @@ final class PreserveUnauthorizedPrivilegedPermissionsAction
* @param array<string, mixed> $originalPermissions
* @return array<string, mixed>
*/
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = [], ?User $targetUser = null): array
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = []): 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'];
@@ -1,308 +0,0 @@
<?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,77 +30,41 @@ class CleanIncorrectCheckoutAcceptances extends Command
{
$deletions = 0;
$skips = 0;
$total = CheckoutAcceptance::count();
$this->info("Processing {$total} checkout acceptances...");
$bar = $this->output->createProgressBar($total);
$bar->start();
// 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");
// 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 = [];
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
foreach ($chunk as $checkoutAcceptance) {
$item = $checkoutAcceptance->checkoutable;
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
if (is_null($item)) {
$skips++;
$bar->advance();
continue;
}
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;
// 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
return;
} else {
$skips++;
// $this->info("The two records are too far apart");
}
$bar->advance();
} else {
// $this->info("No match! checkout to id: " . $checkout_to_id." target_id: ".$assetlog->target_id." target_type: ".$assetlog->target_type);
}
// 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}");
}
$skips++;
});
$this->error("Final deletion count: $deletions, and skip count: $skips");
}
}
+1 -40
View File
@@ -5,7 +5,6 @@ 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
{
@@ -48,7 +47,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 any of the following credentials to login to the demo:\n\n- `admin` / `password`\n- `assets` / `password`\n- `testuser` / `password`";
$settings->login_note = 'Use `admin` / `password` to login to the demo.';
$settings->header_color = '#3c8dbc';
$settings->link_dark_color = '#5fa4cc';
$settings->link_light_color = '#296282;';
@@ -86,44 +85,6 @@ 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')));
+52 -63
View File
@@ -4,9 +4,6 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use function Laravel\Prompts\info;
use function Laravel\Prompts\select;
class Version extends Command
{
/**
@@ -14,7 +11,7 @@ class Version extends Command
*
* @var string
*/
protected $signature = 'version:update';
protected $signature = 'version:update {--branch=master} {--type=patch}';
/**
* The console command description.
@@ -40,40 +37,30 @@ class Version extends Command
*/
public function handle()
{
$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,
);
$use_branch = $this->option('branch');
$use_type = $this->option('type');
$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 = $version[0];
$app_version = $current_app_version = $version[0];
$hash_version = (array_key_exists('2', $version)) ? $version[2] : '';
$prerelease_version = '';
if (array_key_exists('3', $version)) {
$prerelease_version = $version[1];
$hash_version = $version[3];
$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];
}
}
$app_version_raw = explode('.', $app_version);
@@ -87,52 +74,54 @@ 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-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') {
} 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') {
$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;
}
if ($use_branch === 'develop' && ! str_ends_with($app_version, '-pre')) {
// 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') {
$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;
$content = <<<PHP
<?php
$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
);
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;
// Construct our file content
$content = <<<CON
<?php
return $array;
CON;
// And finally write the file and output the current version
\File::put($versionFile, $content);
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.');
}
$this->info('Setting NEW version: '.$full_app_version.' ('.$git_branch.')');
}
}
-3
View File
@@ -31,9 +31,6 @@ enum ActionType: string
case DeleteSeats = 'delete seats';
case AddSeats = 'add seats';
// Maintenances
case MaintenanceComplete = 'completed';
// File Uploads
case Uploaded = 'uploaded';
case UploadDeleted = 'upload deleted';
-7
View File
@@ -19,7 +19,6 @@ use Illuminate\Validation\ValidationException;
use Intervention\Image\Exception\NotSupportedException;
use JsonException;
use League\OAuth2\Server\Exception\OAuthServerException;
use Livewire\Exceptions\PublicPropertyNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
@@ -42,7 +41,6 @@ class Handler extends ExceptionHandler
JsonException::class,
SCIMException::class, // these generally don't need to be reported
InvalidFormatException::class,
PublicPropertyNotFoundException::class,
];
/**
@@ -73,11 +71,6 @@ 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);
}
// CSRF token mismatch error
if ($e instanceof TokenMismatchException) {
return redirect()->back()->with('error', trans('general.token_expired'));
-3
View File
@@ -78,7 +78,6 @@ 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';
@@ -127,7 +126,6 @@ class IconHelper
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';
@@ -158,7 +156,6 @@ class IconHelper
case 'remote':
return 'fa-solid fa-house-laptop';
case 'more-info':
case 'help':
case 'support':
return 'far fa-life-ring';
case 'plus':
@@ -4,9 +4,9 @@ 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;
@@ -18,7 +18,7 @@ use Illuminate\Http\Request;
class AccessoryCheckoutController extends Controller
{
use CheckInOutTrait;
use CheckInOutRequest;
/**
* Return the form to checkout an Accessory to a user.
@@ -149,9 +149,6 @@ 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 ($requiresSignature) {
@@ -237,7 +234,7 @@ class AcceptanceController extends Controller
if ($request->input('asset_acceptance') === 'accepted') {
$pdf_filename = 'accepted-'.$username_slug.$asset_tag_slug.'-'.date('Y-m-d-h-i-s').'.pdf';
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
// Generate the PDF content
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
+2 -3
View File
@@ -4,6 +4,7 @@ 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;
@@ -16,9 +17,6 @@ class ActionlogController extends Controller
{
$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);
@@ -31,6 +29,7 @@ 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);
@@ -4,28 +4,26 @@ 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 CheckInOutTrait;
use CheckInOutRequest;
/**
* Display a listing of the resource.
@@ -234,10 +232,6 @@ 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);
}
@@ -306,49 +300,40 @@ class AccessoriesController extends Controller
{
$this->authorize('checkout', $accessory);
$target = $this->determineCheckoutTarget();
$accessory->checkout_qty = $request->input('checkout_qty', 1);
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')));
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,
];
}
$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,
));
});
// Set this value to be able to pass the qty through to the event
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));
@@ -405,7 +390,6 @@ class AccessoriesController extends Controller
*/
public function selectlist(Request $request)
{
$this->authorize('view.selectlists');
$accessories = Accessory::select([
'accessories.id',
+26 -131
View File
@@ -371,12 +371,6 @@ 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.
@@ -596,7 +590,6 @@ class AssetsController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$assets = Asset::select([
'assets.id',
@@ -609,11 +602,8 @@ class AssetsController extends Controller
])->with('model', 'status', 'assignedTo')
->NotArchived();
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 ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$assets->where('assets.company_id', $request->input('companyId'));
}
if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
@@ -716,35 +706,18 @@ class AssetsController extends Controller
}
}
$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 ($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'));
}
}
$stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
if (isset($target)) {
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
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();
}
@@ -819,54 +792,25 @@ class AssetsController extends Controller
}
}
}
$target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
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;
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;
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) {
if (isset($target)) {
// Using `->has` preserves the asset name if the name parameter was not included in request.
$asset_name = request()->has('name') ? request('name') : $asset->name;
$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]);
}
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location);
}
return true;
});
if ($updated) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -885,36 +829,6 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()), 200);
}
private function resolveCheckoutTargetForAssetMutation(Request $request, ?int $assetId = null): User|Asset|Location|null
{
if ($request->filled('assigned_user')) {
return User::withoutGlobalScopes()->find($request->input('assigned_user'));
}
if ($request->filled('assigned_asset')) {
return Asset::withoutGlobalScopes()->where('id', '!=', $assetId)->find($request->input('assigned_asset'));
}
if ($request->filled('assigned_location')) {
return Location::withoutGlobalScopes()->find($request->input('assigned_location'));
}
return null;
}
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
{
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($target->company_id))
&& ((int) $asset->company_id !== (int) $target->company_id)
) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
return null;
}
/**
* Delete a given asset (mark as deleted).
*
@@ -991,7 +905,6 @@ 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);
}
@@ -1027,22 +940,19 @@ class AssetsController extends Controller
// This item is checked out to a location
if (request('checkout_to_type') == 'location') {
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Location::withoutGlobalScopes()->find(request('assigned_location'));
$target = Location::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') {
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
$target = Asset::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
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = User::withoutGlobalScopes()->find(request('assigned_user'));
$target = User::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';
@@ -1061,16 +971,6 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
}
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
$targetCompanyId = data_get($target, 'company_id');
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($targetCompanyId))
&& ((int) $asset->company_id !== (int) $targetCompanyId)
) {
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, trans('general.error_user_company')));
}
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
$expected_checkin = request('expected_checkin', null);
$note = request('note', null);
@@ -1085,12 +985,7 @@ class AssetsController extends Controller
// $asset->location_id = $target->rtd_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) {
if ($asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id)) {
return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
}
@@ -11,7 +11,6 @@ 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;
@@ -315,33 +314,20 @@ 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'));
if (! $asset) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
$asset = Asset::find($request->input('assigned_to'));
$component->assigned_to = $request->input('assigned_to');
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->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'),
]);
// 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));
});
$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')));
}
@@ -13,11 +13,9 @@ 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
{
@@ -308,42 +306,34 @@ class ConsumablesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining()])));
}
// 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'))) {
// Check if the user exists - @TODO: this should probably be handled via validation, not here??
if (! $user = User::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');
// 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'),
]
);
}
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')));
@@ -356,8 +346,6 @@ class ConsumablesController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$consumables = Consumable::select([
'consumables.id',
'consumables.name',
@@ -8,11 +8,9 @@ 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
{
@@ -27,7 +25,7 @@ class LicenseSeatsController extends Controller
if ($license = License::find($licenseId)) {
$this->authorize('view', $license);
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department', 'user.companies', 'asset.company')
$seats = LicenseSeat::with('license', 'user', 'asset', 'user.department', 'user.company', 'asset.company')
->where('license_seats.license_id', $licenseId);
if ($request->input('status') == 'available') {
@@ -108,8 +106,7 @@ class LicenseSeatsController extends Controller
'prohibits:asset_id',
// must be a valid user or null to unassign
function ($attribute, $value, $fail) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! User::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
if (! is_null($value) && ! User::where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected assigned_to is invalid.');
}
},
@@ -121,8 +118,7 @@ class LicenseSeatsController extends Controller
'prohibits:assigned_to',
// must be a valid asset or null to unassign
function ($attribute, $value, $fail) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! Asset::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
if (! is_null($value) && ! Asset::where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected asset_id is invalid.');
}
},
@@ -132,141 +128,77 @@ class LicenseSeatsController extends Controller
$this->authorize('checkout', License::class);
$errorResponse = null;
$updatedSeat = null;
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])->find($seatId);
// 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);
if (! $licenseSeat) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
}
if (! $licenseSeat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
$license = $licenseSeat->license;
if (! $license || $license->id != intval($licenseId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
}
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;
$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'));
}
}
if ($licenseSeat->save()) {
if ($assignmentTouched) {
if ($is_checkin) {
if (! $licenseSeat->license->reassignable) {
$licenseSeat->unreassignable_seat = true;
if (! $licenseSeat->save()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
$licenseSeat->save();
}
// 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);
}
}
$updatedSeat = $licenseSeat;
});
if ($errorResponse) {
return $errorResponse;
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
}
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);
return Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors());
}
}
+2 -171
View File
@@ -2,21 +2,15 @@
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;
@@ -34,7 +28,7 @@ class LicensesController extends Controller
{
$this->authorize('view', License::class);
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser', 'licenseSeatsRelation', 'assignedCount')->withCount('freeSeats as free_seats_count');
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser')->withCount('freeSeats as free_seats_count');
$settings = Setting::getSettings();
if ($request->input('status') == 'inactive') {
@@ -253,7 +247,7 @@ class LicensesController extends Controller
if ($license->assigned_seats_count == 0) {
// Delete the license and the associated license seats
DB::table('license_seats')
->where('license_id', $license->id)
->where('id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$licenseSeats = $license->licenseseats();
@@ -267,167 +261,6 @@ 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
*
@@ -435,8 +268,6 @@ class LicensesController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$licenses = License::select([
'licenses.id',
'licenses.name',
@@ -427,10 +427,6 @@ class LocationsController extends Controller
$locations = Company::scopeCompanyables($locations);
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$locations->where('locations.company_id', $request->input('companyId'));
}
$page = 1;
if ($request->filled('page')) {
$page = $request->input('page');
@@ -1,87 +0,0 @@
<?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() : abs($request->input('offset'));
$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,19 +2,15 @@
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;
@@ -42,8 +38,7 @@ class MaintenancesController extends Controller
$this->authorize('view', Asset::class);
$maintenances = Maintenance::select('maintenances.*')
->whereHas('asset')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo', 'maintenanceType', 'responsibleParty', 'completedByUser');
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo');
// 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')) {
@@ -66,39 +61,8 @@ class MaintenancesController extends Controller
$maintenances->where('maintenances.url', '=', $request->input('url'));
}
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;
}
if ($request->filled('asset_maintenance_type')) {
$maintenances->where('asset_maintenance_type', '=', $request->input('asset_maintenance_type'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -109,10 +73,10 @@ class MaintenancesController extends Controller
'id',
'name',
'asset_maintenance_time',
'asset_maintenance_type',
'cost',
'start_date',
'completion_date',
'completed_at',
'notes',
'asset_tag',
'asset_name',
@@ -124,7 +88,6 @@ class MaintenancesController extends Controller
'status_label',
'model',
'model_number',
'maintenance_type',
];
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
@@ -132,37 +95,31 @@ 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);
break;
case 'maintenance_type':
$maintenances = $maintenances->orderByMaintenanceType($order);
break;
case 'completed_at':
$maintenances = $maintenances->orderByCompletedAt($order);
$maintenances = $maintenances->OrderStatusName($order);
break;
default:
$maintenances = $maintenances->orderBy($sort, $order);
@@ -195,60 +152,19 @@ class MaintenancesController extends Controller
{
$this->authorize('update', Asset::class);
$isBulk = $request->has('asset_ids');
$assetIds = $isBulk
? array_values(array_filter((array) $request->input('asset_ids')))
: [$request->input('asset_id')];
// 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')));
$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();
}
}
if ($isBulk) {
if ($created->isEmpty()) {
return response()->json(Helper::formatStandardApiResponse('error', null, count($errors) === 1 ? $errors[0] : $errors));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenance->getErrors()));
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));
}
/**
@@ -339,35 +255,6 @@ class MaintenancesController extends Controller
}
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);
@@ -379,50 +266,4 @@ class MaintenancesController extends Controller
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);
}
}
+7 -154
View File
@@ -6,18 +6,8 @@ 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
@@ -36,18 +26,18 @@ class ReportsController extends Controller
// 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 ((! 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);
$target = Helper::normalizeFullModelName(request()->input('target_type'));
$target::find(request()->input('target_id'))?->withTrashed();
$this->authorize('view', $target);
}
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);
$item = Helper::normalizeFullModelName(request()->input('item_type'));
$item::find(request()->input('item_id'))?->withTrashed();
$this->authorize('view', $item);
}
} else {
@@ -135,141 +125,4 @@ class ReportsController extends Controller
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
/**
* Returns time-series data for the reports overview charts.
*
* Accepts ?days=N (preset, default 30) OR ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD.
* Also returns the immediately preceding period of equal length for comparison lines.
*/
public function activityChart(Request $request): JsonResponse
{
$this->authorize('reports.view');
$allowedDays = [7, 14, 30, 60, 90, 180, 365];
if ($request->filled('start_date') && $request->filled('end_date')) {
$curStart = Carbon::parse($request->input('start_date'))->startOfDay();
$curEnd = Carbon::parse($request->input('end_date'))->endOfDay();
if ($curEnd->lt($curStart)) {
[$curStart, $curEnd] = [$curEnd, $curStart];
}
$days = max(1, (int) $curStart->diffInDays($curEnd) + 1);
} else {
$days = in_array((int) $request->input('days'), $allowedDays) ? (int) $request->input('days') : 30;
$curEnd = Carbon::today()->endOfDay();
$curStart = Carbon::today()->subDays($days - 1)->startOfDay();
}
$prevEnd = $curStart->copy()->subSecond()->endOfDay();
$prevStart = $prevEnd->copy()->subDays($days - 1)->startOfDay();
$buildDates = function (Carbon $start, Carbon $end): array {
$dates = [];
for ($d = $start->copy(); $d->lte($end); $d->addDay()) {
$dates[] = $d->toDateString();
}
return $dates;
};
$curDates = $buildDates($curStart, $curEnd);
$prevDates = $buildDates($prevStart, $prevEnd);
$pluckAction = function (string $actionType, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// withTrashed() ensures records deleted after creation still appear in their creation-period counts.
$pluckCreated = function (string $modelClass, Carbon $start, Carbon $end): array {
return $modelClass::withTrashed()
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Maintenance has no company_id column and no CompanyableTrait, so scope through
// its asset relationship — whereHas('asset') applies Asset's FMCS global scope.
$pluckMaintenances = function (Carbon $start, Carbon $end): array {
return Maintenance::withTrashed()
->whereHas('asset')
->whereBetween('maintenances.created_at', [$start, $end])
->selectRaw('DATE(maintenances.created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Filters by both action_type and item_type for per-category checkout/checkin counts.
$pluckActionByType = function (string $actionType, string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$pluckDeletedUsers = function (Carbon $start, Carbon $end): array {
return User::withTrashed()
->whereNotNull('deleted_at')
->whereBetween('deleted_at', [$start, $end])
->selectRaw('DATE(deleted_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Catches both 'checkin' and 'checkin from' action types used across different item types.
$pluckCheckinsByType = function (string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::whereIn('action_type', ['checkin', 'checkin from'])
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$fill = fn (array $raw, array $dates) => array_map(fn ($d) => (int) ($raw[$d] ?? 0), $dates);
$datasets = [];
foreach ([
'new_users' => fn ($s, $e) => $pluckCreated(User::class, $s, $e),
'deleted_users' => fn ($s, $e) => $pluckDeletedUsers($s, $e),
'asset_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Asset::class, $s, $e),
'asset_checkins' => fn ($s, $e) => $pluckCheckinsByType(Asset::class, $s, $e),
'new_assets' => fn ($s, $e) => $pluckCreated(Asset::class, $s, $e),
'new_maintenances' => fn ($s, $e) => $pluckMaintenances($s, $e),
'new_audits' => fn ($s, $e) => $pluckAction('audit', $s, $e),
'component_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Component::class, $s, $e),
'component_checkins' => fn ($s, $e) => $pluckCheckinsByType(Component::class, $s, $e),
'new_components' => fn ($s, $e) => $pluckCreated(Component::class, $s, $e),
'consumable_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Consumable::class, $s, $e),
'consumable_checkins' => fn ($s, $e) => $pluckCheckinsByType(Consumable::class, $s, $e),
'new_consumables' => fn ($s, $e) => $pluckCreated(Consumable::class, $s, $e),
'license_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', LicenseSeat::class, $s, $e),
'license_checkins' => fn ($s, $e) => $pluckCheckinsByType(LicenseSeat::class, $s, $e),
'new_licenses' => fn ($s, $e) => $pluckCreated(License::class, $s, $e),
'accessory_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Accessory::class, $s, $e),
'accessory_checkins' => fn ($s, $e) => $pluckCheckinsByType(Accessory::class, $s, $e),
'new_accessories' => fn ($s, $e) => $pluckCreated(Accessory::class, $s, $e),
] as $key => $query) {
$datasets[$key] = $fill($query($curStart, $curEnd), $curDates);
$datasets['prev_'.$key] = $fill($query($prevStart, $prevEnd), $prevDates);
}
return response()->json(array_merge([
'labels' => array_map(fn ($d) => Carbon::parse($d)->format('M j'), $curDates),
'prev_label' => $prevStart->format('M j').' '.$prevEnd->format('M j'),
], $datasets));
}
}
+8 -27
View File
@@ -22,7 +22,6 @@ 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;
@@ -52,6 +51,7 @@ class UsersController extends Controller
'users.address',
'users.avatar',
'users.city',
'users.company_id',
'users.country',
'users.created_by',
'users.created_at',
@@ -89,7 +89,7 @@ class UsersController extends Controller
])->with('manager')
->with('groups')
->with('userloc')
->with('companies')
->with('company')
->with('department')
->with('createdBy')
->withCount([
@@ -191,7 +191,7 @@ class UsersController extends Controller
}
if ($request->filled('company_id')) {
$users = $users->whereHas('companies', fn ($q) => $q->where('companies.id', $request->input('company_id')));
$users = $users->where('users.company_id', '=', $request->input('company_id'));
}
if ($request->filled('phone')) {
@@ -380,8 +380,6 @@ class UsersController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$users = User::select(
[
'users.id',
@@ -396,13 +394,6 @@ class UsersController extends Controller
]
)->where('show_in_list', '=', '1');
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
if (! empty($companyIds)) {
$users->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
}
}
if ($request->filled('search')) {
$users = $users->where(function ($query) use ($request) {
$query->SimpleNameSearch($request->input('search'))
@@ -450,6 +441,7 @@ class UsersController extends Controller
$authenticatedUser = auth()->user();
$user = new User;
$user->fill($request->all());
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$user->created_by = auth()->id();
if ($request->has('permissions')) {
@@ -494,12 +486,6 @@ 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')));
}
@@ -583,12 +569,15 @@ class UsersController extends Controller
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
authenticatedUser: $authenticatedUser,
originalPermissions: NormalizePermissionsPayloadAction::run($user->decodePermissions()),
targetUser: $user,
));
}
}
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'));
}
@@ -617,14 +606,6 @@ class UsersController extends Controller
$user->groups()->sync($request->input('groups'));
}
// Sync company memberships when company_ids[] or company_id is provided
if ($request->has('company_ids') || $request->filled('company_id')) {
$companyIds = array_filter(
(array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))
);
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser(array_map('intval', $companyIds)));
}
return response()->json(Helper::formatStandardApiResponse('success', (new UsersTransformer)->transformUser($user), trans('admin/users/message.success.update')));
}
@@ -84,7 +84,7 @@ class AssetCheckinController extends Controller
public function store(AssetCheckinRequest $request, $assetId = null, $backto = null): RedirectResponse
{
// Check if the asset exists
if (is_null($asset = Asset::withTrashed()->find($assetId))) {
if (is_null($asset = Asset::find($assetId))) {
// Redirect to the asset management page with error
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
}
@@ -135,16 +135,12 @@ class AssetCheckinController extends Controller
$asset->location_id = $asset->rtd_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;
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');
}
}
@@ -4,9 +4,9 @@ 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\CheckoutAcceptance;
use App\Models\Setting;
@@ -17,7 +17,7 @@ use Illuminate\Http\RedirectResponse;
class AssetCheckoutController extends Controller
{
use CheckInOutTrait;
use CheckInOutRequest;
/**
* Returns a view that presents a form to check an asset out to a
@@ -511,7 +511,7 @@ class AssetsController extends Controller
// Validate required serial based on model setting
if ($model && $model->require_serial === 1 && empty($serial[1])) {
return Helper::getRedirectOption($request, $asset->id, 'Assets')
return redirect()->to(Helper::getRedirectOption($request, $asset->id, 'Assets'))
->with('warning', trans('admin/hardware/form.serial_required_post_model_update', [
'asset_model' => $model->name,
]));
@@ -567,12 +567,11 @@ class AssetsController extends Controller
*
* @since [v3.0]
*/
public function getAssetBySerial(Request $request, $serial = null): RedirectResponse
public function getAssetBySerial(Request $request): RedirectResponse
{
$serial = $serial ?: $request->input('serial');
$topsearch = ($request->input('topsearch') == 'true');
if (! $asset = Asset::where('serial', '=', $serial)->first()) {
if (! $asset = Asset::where('serial', '=', $request->input('serial'))->first()) {
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
}
$this->authorize('view', $asset);
@@ -2,24 +2,20 @@
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\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;
@@ -31,7 +27,7 @@ use Illuminate\Support\Facades\Log;
class BulkAssetsController extends Controller
{
use CheckInOutTrait;
use CheckInOutRequest;
/**
* Display the bulk edit page.
@@ -77,16 +73,6 @@ 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]);
@@ -773,112 +759,6 @@ 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);
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Traits;
namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\Location;
use App\Models\SnipeModel;
use App\Models\User;
trait CheckInOutTrait
trait CheckInOutRequest
{
/**
* Find target for checkout
@@ -4,8 +4,7 @@ namespace App\Http\Controllers\Components;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreComponentRequest;
use App\Http\Requests\UpdateComponentRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Models\Company;
use App\Models\Component;
use Illuminate\Auth\Access\AuthorizationException;
@@ -13,6 +12,7 @@ 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(StoreComponentRequest $request)
public function store(ImageUploadRequest $request)
{
$this->authorize('create', Component::class);
$component = new Component;
@@ -148,10 +148,21 @@ class ComponentsController extends Controller
*
* @since [v3.0]
*/
public function update(UpdateComponentRequest $request, Component $component)
public function update(ImageUploadRequest $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');
@@ -54,7 +54,6 @@ 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);
@@ -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 CheckInOutTrait;
use CheckInOutRequest;
public function __construct(PredefinedKitCheckoutService $kitService)
{
@@ -1,67 +0,0 @@
<?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,7 +13,6 @@ 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;
@@ -128,45 +127,10 @@ 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)
{
@@ -15,7 +15,6 @@ 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
@@ -95,31 +94,23 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
}
$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);
}
});
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId);
$licenseSeat->created_by = auth()->id();
$licenseSeat->notes = $request->input('notes');
if ($request->filled('asset_id')) {
session()->put(['checkout_to_type' => 'asset']);
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
$request->request->add(['assigned_asset' => $checkoutTarget->id]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'asset',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
} elseif ($request->filled('assigned_to')) {
session()->put(['checkout_to_type' => 'user']);
$checkoutTarget = $this->checkoutToUser($licenseSeat);
$request->request->add(['assigned_user' => $checkoutTarget->id]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
@@ -165,11 +156,9 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('Something went wrong handling this checkout.'));
}
protected function findLicenseSeatToCheckout($license, $seatId, bool $lock = false)
protected function findLicenseSeatToCheckout($license, $seatId)
{
$licenseSeat = $seatId
? LicenseSeat::where('id', $seatId)->when($lock, fn ($q) => $q->lockForUpdate())->first()
: $license->freeSeat(lock: $lock);
$licenseSeat = LicenseSeat::find($seatId) ?? $license->freeSeat();
if (! $licenseSeat) {
if ($seatId) {
@@ -12,7 +12,6 @@ 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;
/**
@@ -389,8 +388,6 @@ class LicensesController extends Controller
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($licenses as $license) {
// Add a new row with data
$values = [
@@ -422,14 +419,7 @@ class LicensesController extends Controller
$license->created_at,
];
// 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));
}
fputcsv($handle, $values);
}
});
+2 -2
View File
@@ -277,7 +277,7 @@ class LocationsController extends Controller
->with('assignedAssets', $location->assignedAssets)
->with('accessories', $location->accessories)
->with('assignedAccessories', $location->assignedAccessories)
->with('users', $location->users()->with('companies')->get())
->with('users', $location->users)
->with('location', $location)
->with('consumables', $location->consumables)
->with('components', $location->components)
@@ -297,7 +297,7 @@ class LocationsController extends Controller
->with('assignedAssets', $location->assignedAssets)
->with('accessories', $location->accessories)
->with('assignedAccessories', $location->assignedAccessories)
->with('users', $location->users()->with('companies')->get())
->with('users', $location->users)
->with('location', $location)
->with('consumables', $location->consumables)
->with('components', $location->components)
@@ -1,72 +0,0 @@
<?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'));
}
}
+28 -44
View File
@@ -2,14 +2,11 @@
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 App\Models\MaintenanceType;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -60,7 +57,6 @@ 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);
}
@@ -86,10 +82,6 @@ class MaintenancesController extends Controller
// 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');
@@ -100,13 +92,20 @@ 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);
}
$request->handleImages($maintenance);
// Was the asset maintenance created?
@@ -142,7 +141,6 @@ 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);
}
@@ -171,12 +169,28 @@ class MaintenancesController extends Controller
$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);
}
$request->handleImages($maintenance);
if ($maintenance->save()) {
@@ -239,36 +253,6 @@ class MaintenancesController extends Controller
)->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,7 +30,6 @@ class ModalController extends Controller
'kit-consumable',
'kit-accessory',
'location',
'maintenance-type',
'manufacturer',
'model',
'statuslabel',
+9 -14
View File
@@ -4,15 +4,13 @@ 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): RedirectResponse
public function store(Request $request)
{
$this->authorize('update', Asset::class);
@@ -21,19 +19,13 @@ class NotesController extends Controller
'note' => 'required|string|max:50000',
'type' => [
'required',
Rule::in(['asset', 'maintenance']),
Rule::in(['asset']),
],
]);
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']);
}
$item = Asset::findOrFail($validated['id']);
$this->authorize('update', $item);
$logaction = new Actionlog;
$logaction->item_id = $item->id;
@@ -42,6 +34,9 @@ class NotesController extends Controller
$logaction->created_by = Auth::id();
$logaction->logaction('note added');
return $redirect->withFragment('notes')->with('success', trans('general.note_added'));
return redirect()
->route('hardware.show', $validated['id'])
->withFragment('history')
->with('success', trans('general.note_added'));
}
}
+7 -19
View File
@@ -8,7 +8,6 @@ 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;
@@ -64,12 +63,6 @@ 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');
@@ -218,19 +211,14 @@ class ProfileController extends Controller
*/
public function printInventory(): View
{
$userId = auth()->id();
$show_users = User::where('id', auth()->user()->id)->get();
$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)
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)
->with('settings', Setting::getSettings());
}
+4 -43
View File
@@ -56,31 +56,6 @@ 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.
*
@@ -277,7 +252,6 @@ 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');
@@ -313,8 +287,6 @@ class ReportsController extends Controller
Log::debug('Walking results: '.$executionTime);
$count = 0;
$formatter = new EscapeFormula('`');
foreach ($actionlogs as $actionlog) {
$count++;
$target_name = '';
@@ -345,15 +317,7 @@ class ReportsController extends Controller
$actionlog->action_source,
$actionlog->log_meta,
];
// 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));
}
fputcsv($handle, $row);
}
});
@@ -424,7 +388,7 @@ class ReportsController extends Controller
$row[] = $license->remaincount();
$row[] = $license->expiration_date;
$row[] = $license->purchase_date;
$row[] = ($license->depreciation != '') ? e($license->depreciation->name) : '';
$row[] = ($license->depreciation != '') ? '' : e($license->depreciation->name);
$row[] = '"'.Helper::formatCurrencyOutput($license->purchase_cost).'"';
$rows[] = implode(',', $row);
@@ -888,7 +852,7 @@ class ReportsController extends Controller
}
if ($request->filled('purchase_date')) {
$row[] = ($asset->purchase_date) ? Carbon::parse($asset->purchase_date)->format('Y-m-d') : '';
$row[] = ($asset->purchase_date) ? $asset->purchase_date : '';
}
if ($request->filled('purchase_cost')) {
@@ -896,7 +860,7 @@ class ReportsController extends Controller
}
if ($request->filled('eol')) {
$row[] = ($asset->asset_eol_date != '') ? Carbon::parse($asset->asset_eol_date)->format('Y-m-d') : '';
$row[] = ($asset->asset_eol_date != '') ? $asset->asset_eol_date : '';
}
if ($request->filled('warranty')) {
@@ -1236,9 +1200,6 @@ class ReportsController extends Controller
public function getAssetAcceptanceReport($deleted = false): View
{
$this->authorize('reports.view');
$this->disableDebugbar();
$showDeleted = $deleted == 'deleted';
$query = CheckoutAcceptance::Pending()
@@ -19,7 +19,6 @@ 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;
@@ -190,13 +189,6 @@ class SettingsController extends Controller
$request->validate(['site_name' => 'required']);
}
$request->validate([
'header_color' => ['nullable', new CssColor],
'link_light_color' => ['nullable', new CssColor],
'link_dark_color' => ['nullable', new CssColor],
'nav_link_color' => ['nullable', new CssColor],
]);
$setting->header_color = $request->input('header_color', '#3c8dbc');
$setting->link_light_color = $request->input('link_light_color', '#296282');
$setting->link_dark_color = $request->input('link_dark_color', '#5fa4cc');
-7
View File
@@ -6,7 +6,6 @@ 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;
@@ -167,12 +166,6 @@ 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');
@@ -8,7 +8,6 @@ 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;
@@ -169,8 +168,11 @@ 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')
@@ -200,7 +202,7 @@ class BulkUsersController extends Controller
$this->update_array['manager_id'] = null;
}
if ($request->input('null_company_ids') == '1') {
if ($request->input('null_company_id') == '1') {
$this->update_array['company_id'] = null;
}
@@ -233,37 +235,11 @@ class BulkUsersController extends Controller
->update(['location_id' => $this->update_array['location_id']]);
}
// Handle company pivot sync separately from the mass update.
// company_ids[] comes from the multi-select; null_company_ids clears all memberships.
$bulkCompanyIds = array_filter(array_map('intval', (array) $request->input('company_ids', [])));
$clearCompanies = $request->input('null_company_ids') == '1';
// Only sync groups if groups were selected
if ($request->filled('groups')) {
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) {
$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 (! empty($authFieldUpdate)) {
$user->update($authFieldUpdate);
}
if ($request->filled('groups') && auth()->user()->isSuperUser()) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$user->groups()->sync($request->input('groups'));
}
}
@@ -422,7 +398,7 @@ class BulkUsersController extends Controller
*/
public function merge(Request $request)
{
$this->authorize('delete', User::class);
$this->authorize('update', User::class);
if (config('app.lock_passwords')) {
return redirect()->route('users.index')->with('error', trans('general.feature_disabled'));
@@ -443,10 +419,6 @@ class BulkUsersController extends Controller
// 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');
@@ -489,12 +461,6 @@ 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));
+67 -114
View File
@@ -26,7 +26,6 @@ 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;
/**
@@ -123,7 +122,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);
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
$user->company_id = Company::getIdForUser($request->input('company_id', null));
$user->manager_id = $request->input('manager_id', null);
$user->notes = $request->input('notes');
$user->address = $request->input('address', null);
@@ -153,7 +152,6 @@ class UsersController extends Controller
}
if ($user->save()) {
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
if (($user->activated == '1') && ($user->email != '') && ($request->input('send_welcome') == '1')) {
@@ -165,7 +163,7 @@ class UsersController extends Controller
}
if (auth()->user()->isSuperUser() && auth()->user()->can('editableOnDemo')) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$user->groups()->sync($request->input('groups'));
}
@@ -276,7 +274,7 @@ class UsersController extends Controller
$user->phone = $request->input('phone');
$user->mobile = $request->input('mobile');
$user->location_id = $request->input('location_id', null);
$companyIds = array_filter(array_map('intval', (array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))));
$user->company_id = Company::getIdForUser($request->input('company_id', null));
$user->manager_id = $request->input('manager_id', null);
$user->notes = $request->input('notes');
$user->department_id = $request->input('department_id', null);
@@ -312,14 +310,11 @@ class UsersController extends Controller
$user->password = bcrypt($request->input('password'));
}
if ($request->has('permission')) {
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
targetUser: $user,
));
}
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
));
// Only save groups if the user is a superuser
if (auth()->user()->isSuperUser()) {
@@ -337,8 +332,6 @@ 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'));
@@ -483,7 +476,7 @@ class UsersController extends Controller
$permissions = $request->input('permissions', []);
app('request')->request->set('permissions', $permissions);
$user_to_clone = User::with('userloc', 'companies')->withTrashed()->find($user->id);
$user_to_clone = User::with('userloc')->withTrashed()->find($user->id);
// Make sure they can view this particular user
$this->authorize('view', $user_to_clone);
@@ -540,76 +533,52 @@ 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.manager',
'department',
'licenses',
'manager',
'groups',
'userloc',
'companies',
'createdBy'
)->withCount(['managesUsers as manages_users_count', 'managedLocations as manages_locations_count'])
->orderBy('created_at', 'DESC')
'company'
)->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'),
];
$formatter = new EscapeFormula('`');
fputcsv($handle, $headers);
foreach ($users as $user) {
$user_groups = '';
foreach ($user->groups as $user_group) {
$user_groups .= $user_group->name.', ';
}
$permissionstring = '';
if ($user->isSuperUser()) {
@@ -623,23 +592,14 @@ class UsersController extends Controller
// Add a new row with data
$values = [
$user->id,
$user->companies->pluck('name')->implode('|'),
($user->company) ? $user->company->name : '',
$user->jobtitle,
$user->employee_num,
$user->first_name,
$user->last_name,
$user->getFullNameAttribute(),
$user->getRawOriginal('display_name'),
$user->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 : '',
@@ -647,37 +607,14 @@ class UsersController extends Controller
$user->licenses->count(),
$user->accessories->count(),
$user->consumables->count(),
$user->groups->pluck('name')->implode(', '),
$user_groups,
$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,
];
// 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));
}
fputcsv($handle, $values);
}
});
@@ -702,16 +639,32 @@ class UsersController extends Controller
{
$this->authorize('view', User::class);
$user = User::withInventoryRelations($id)->first();
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
$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();
if ($user) {
$this->authorize('view', $user);
return view('users.print')
->with('users', [$user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
+4 -20
View File
@@ -19,7 +19,6 @@ 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
@@ -121,7 +120,6 @@ 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
@@ -201,23 +199,13 @@ class ViewAssetsController extends Controller
$settings = Setting::getSettings();
$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);
if (($item_request = $item->isRequestedBy($user)) || $cancel_by_admin) {
$item->cancelRequest($requestingUser);
$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'))) {
try {
$settings->notify((new RequestAssetCancelation($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send request cancellation notification: '.$e->getMessage());
}
$settings->notify((new RequestAssetCancelation($data))->locale($settings->locale));
}
return redirect()->back()->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
@@ -225,11 +213,7 @@ class ViewAssetsController extends Controller
$item->request();
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$logaction->logaction('requested');
try {
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send asset request notification: '.$e->getMessage());
}
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
}
return redirect()->route('requestable-assets')->with('success')->with('success', trans('admin/hardware/message.requests.success'));
-2
View File
@@ -17,7 +17,6 @@ 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;
@@ -85,7 +84,6 @@ class Kernel extends HttpKernel
'auth:api',
CheckLocale::class,
LogAuthedUserHeader::class,
SetPaginationDefaults::class,
SubstituteBindings::class,
],
@@ -1,34 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SetPaginationDefaults
{
public function handle(Request $request, Closure $next)
{
$limit = config('app.max_results');
$intLimit = intval($request->input('limit'));
if (abs($intLimit) > 0 && $intLimit <= config('app.max_results')) {
$limit = abs($intLimit);
}
app()->instance('api_limit_value', $limit);
if ($request->filled('page') && ! $request->filled('offset')) {
$page = max(1, intval($request->input('page')));
$offset = ($page - 1) * $limit;
} else {
$offset = intval($request->input('offset'));
$page = $limit > 0 ? (int) floor($offset / $limit) + 1 : 1;
}
app()->instance('api_offset_value', $offset);
app()->instance('api_current_page', $page);
return $next($request);
}
}
@@ -2,7 +2,6 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\AssetModel;
@@ -27,10 +26,6 @@ class CreateMultipleAssetRequest extends ImageUploadRequest // should I extend f
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if (Setting::getSettings()->full_multiple_companies_support == '1' && ! $this->user()->isSuperUser()) {
$this->mergeIfMissing(['company_id' => $this->user()->company_id]);
}
+2 -2
View File
@@ -41,7 +41,7 @@ class ItemImportRequest extends FormRequest
$classString = "App\\Importer\\{$class}Importer";
$importer = new $classString($filename);
$import->field_map = request('column-mappings');
$import->created_by = $import->created_by ?? auth()->id();
$import->created_by = auth()->id();
$import->save();
$fieldMappings = [];
@@ -51,7 +51,7 @@ class ItemImportRequest extends FormRequest
if (is_null($fieldValue)) {
$errorMessage = trans('validation.import_field_empty', ['fieldname' => $field]);
$this->errorCallback($import, $field, [$field => [$errorMessage]]);
$this->errorCallback($import, $field, [$field => $errorMessage]);
return $this->errors;
}
+1 -5
View File
@@ -2,7 +2,6 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Accessory;
use App\Models\Category;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -22,10 +21,6 @@ class StoreAccessoryRequest extends ImageUploadRequest
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if ($this->category_id) {
if ($category = Category::find($this->category_id)) {
$this->merge([
@@ -33,6 +28,7 @@ class StoreAccessoryRequest extends ImageUploadRequest
]);
}
}
}
/**
+26 -4
View File
@@ -2,10 +2,10 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Setting;
use App\Rules\AssetCannotBeCheckedOutToNondeployableStatus;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
@@ -39,9 +39,6 @@ class StoreAssetRequest extends ImageUploadRequest
$this->merge([
'asset_tag' => $this->asset_tag ?? Asset::autoincrement_asset(),
'company_id' => $idForCurrentUser,
'purchase_cost' => $this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))
? Helper::ParseCurrency($this->input('purchase_cost'))
: $this->input('purchase_cost'),
]);
}
@@ -52,6 +49,15 @@ class StoreAssetRequest extends ImageUploadRequest
{
$modelRules = (new Asset)->getRules();
if (Setting::getSettings()->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
// If purchase_cost was submitted as a string with a comma separator
// then we need to ignore the normal numeric rules.
// Since the original rules still live on the model they will be run
// right before saving (and after purchase_cost has been
// converted to a float via setPurchaseCostAttribute).
$modelRules = $this->removeNumericRulesFromPurchaseCost($modelRules);
}
return array_merge(
$modelRules,
['status_id' => [new AssetCannotBeCheckedOutToNondeployableStatus]],
@@ -75,4 +81,20 @@ class StoreAssetRequest extends ImageUploadRequest
}
}
}
private function removeNumericRulesFromPurchaseCost(array $rules): array
{
$purchaseCost = $rules['purchase_cost'];
// If rule is in "|" format then turn it into an array
if (is_string($purchaseCost)) {
$purchaseCost = explode('|', $purchaseCost);
}
$rules['purchase_cost'] = array_filter($purchaseCost, function ($rule) {
return $rule !== 'numeric' && $rule !== 'gte:0';
});
return $rules;
}
}
@@ -1,27 +0,0 @@
<?php
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Component;
use Illuminate\Support\Facades\Gate;
class StoreComponentRequest extends ImageUploadRequest
{
public function authorize(): bool
{
return Gate::allows('create', Component::class);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
public function response(array $errors)
{
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
}
}
+1 -5
View File
@@ -2,7 +2,6 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Category;
use App\Models\Consumable;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -22,10 +21,6 @@ class StoreConsumableRequest extends ImageUploadRequest
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if ($this->category_id) {
if ($category = Category::find($this->category_id)) {
$this->merge([
@@ -33,6 +28,7 @@ class StoreConsumableRequest extends ImageUploadRequest
]);
}
}
}
/**
+6 -8
View File
@@ -2,7 +2,6 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\Setting;
@@ -23,13 +22,6 @@ class UpdateAssetRequest extends ImageUploadRequest
return Gate::allows('update', $this->asset);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
/**
* Get the validation rules that apply to the request.
*
@@ -59,6 +51,12 @@ class UpdateAssetRequest extends ImageUploadRequest
],
);
// if the purchase cost is passed in as a string **and** the digit_separator is ',' (as is common in the EU)
// then we tweak the purchase_cost rule to make it a string
if ($setting->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
$rules['purchase_cost'] = ['nullable', 'string'];
}
return $rules;
}
}
@@ -1,35 +0,0 @@
<?php
namespace App\Http\Requests;
use App\Helpers\Helper;
use Illuminate\Support\Facades\Gate;
class UpdateComponentRequest extends ImageUploadRequest
{
public function authorize(): bool
{
return Gate::allows('update', $this->component);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
public function rules(): array
{
$min = $this->component->numCheckedOut();
return array_merge(parent::rules(), [
'qty' => "required|numeric|min:{$min}",
]);
}
public function response(array $errors)
{
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
}
}
@@ -116,10 +116,10 @@ class ActionlogsTransformer
$clean_meta[$fieldname]['old'] = '************';
$clean_meta[$fieldname]['new'] = '************';
// Display the changes if the user has permission to view encrypted custom fields
if (Gate::allows('assets.view.encrypted_custom_fields')) {
$clean_meta[$fieldname]['old'] = ($enc_old) ? e(unserialize($enc_old, ['allowed_classes' => false])) : '';
$clean_meta[$fieldname]['new'] = ($enc_new) ? e(unserialize($enc_new, ['allowed_classes' => false])) : '';
// Display the changes if the user is an admin or superadmin
if (Gate::allows('admin')) {
$clean_meta[$fieldname]['old'] = ($enc_old) ? unserialize($enc_old, ['allowed_classes' => false]) : '';
$clean_meta[$fieldname]['new'] = ($enc_new) ? unserialize($enc_new, ['allowed_classes' => false]) : '';
}
}
@@ -293,28 +293,6 @@ class ActionlogsTransformer
$clean_meta[trans('general.company')] = $clean_meta['company_id'];
unset($clean_meta['company_id']);
}
if (array_key_exists('companies', $clean_meta)) {
// clean_field() JSON-encodes array values into a string (e.g. "[14,15]").
// Decode them back to integer arrays before resolving names.
// Use withoutGlobalScopes so FMCS does not hide companies from the log viewer.
$resolveCompanyNames = function ($rawValue): string {
$ids = json_decode($rawValue, true);
if (empty($ids) || ! is_array($ids)) {
return trans('general.unassigned');
}
return collect($ids)
->map(fn ($id) => Company::withoutGlobalScopes()->withTrashed()->find($id))
->map(fn ($c) => $c ? e($c->name) : trans('general.deleted'))
->join(', ');
};
$clean_meta['companies']['old'] = $resolveCompanyNames($clean_meta['companies']['old']);
$clean_meta['companies']['new'] = $resolveCompanyNames($clean_meta['companies']['new']);
$clean_meta[trans('general.companies')] = $clean_meta['companies'];
unset($clean_meta['companies']);
}
if (array_key_exists('supplier_id', $clean_meta)) {
$oldSupplier = $supplier->find($clean_meta['supplier_id']['old']);
@@ -388,9 +388,6 @@ class AssetsTransformer
$permissions_array['available_actions'] = [
'checkout' => false,
'checkin' => Gate::allows('checkin', License::class),
'bulk_selectable' => [
'checkin' => Gate::allows('checkin', License::class),
],
];
$array += $permissions_array;
@@ -75,9 +75,6 @@ class CategoriesTransformer
$permissions_array['available_actions'] = [
'update' => Gate::allows('update', Category::class),
'delete' => $category->isDeletable(),
'bulk_selectable' => [
'delete' => $category->isDeletable(),
],
];
$array += $permissions_array;
@@ -9,24 +9,8 @@ class DatatablesTransformer
**/
public function transformDatatables($objects, $total = null)
{
$objects_array = [
'total' => $total ?? count($objects),
'rows' => $objects,
];
$current_page = app('api_current_page');
$limit = (int) app('api_limit_value');
$total_pages = $limit > 0 ? (int) ceil($objects_array['total'] / $limit) : 1;
$objects_array['current_page'] = $current_page;
$objects_array['per_page'] = $limit;
$objects_array['total_pages'] = $total_pages;
$objects_array['prev_page_url'] = $current_page > 1
? request()->fullUrlWithQuery(['page' => $current_page - 1])
: null;
$objects_array['next_page_url'] = $current_page < $total_pages
? request()->fullUrlWithQuery(['page' => $current_page + 1])
: null;
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
$objects_array['rows'] = $objects;
return $objects_array;
}
@@ -36,10 +20,8 @@ class DatatablesTransformer
**/
public function transformBulkResponseWithStatusAndObjects($objects, $total)
{
$objects_array = [
'total' => $total ?? count($objects),
'rows' => $objects,
];
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
$objects_array['rows'] = $objects;
return $objects_array;
}
@@ -38,11 +38,13 @@ class LicenseSeatsTransformer
'tag_color' => $seat->user->department->tag_color ? e($seat->user->department->tag_color) : null,
] : null,
'companies' => $seat->user->companies->map(fn ($c) => [
'id' => (int) $c->id,
'name' => e($c->name),
'tag_color' => $c->tag_color ? e($c->tag_color) : null,
])->values(),
'company' => ($seat->user->company) ?
[
'id' => (int) $seat->user->company->id,
'name' => e($seat->user->company->name),
'tag_color' => $seat->user->company->tag_color ? e($seat->user->company->tag_color) : null,
] : null,
'created_at' => Helper::getFormattedDateObject($seat->created_at, 'datetime'),
] : null,
'assigned_asset' => ($seat->asset) ? [
@@ -68,9 +70,6 @@ class LicenseSeatsTransformer
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => Gate::allows('delete', License::class),
'bulk_selectable' => [
'checkin' => Gate::allows('checkin', License::class) && ($seat->assigned_to || $seat->asset_id),
],
];
$array += $permissions_array;
@@ -66,6 +66,7 @@ class LicensesTransformer
'created_at' => Helper::getFormattedDateObject($license->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($license->updated_at, 'datetime'),
'deleted_at' => Helper::getFormattedDateObject($license->deleted_at, 'datetime'),
'user_can_checkout' => (bool) ($license->free_seats_count > 0),
'disabled' => $license->isInactive(),
];
@@ -74,11 +75,7 @@ class LicensesTransformer
'checkin' => Gate::allows('checkin', License::class),
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => $license->isDeletable(),
'user_can_checkout' => (bool) (($license->free_seats_count - License::unReassignableCount($license)) > 0),
'bulk_selectable' => [
'delete' => $license->isDeletable(),
],
'delete' => (Gate::allows('delete', License::class) && ($license->free_seats_count == $license->seats)) ? true : false,
];
$array += $permissions_array;
@@ -1,37 +0,0 @@
<?php
namespace App\Http\Transformers;
use App\Helpers\Helper;
use App\Models\MaintenanceType;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class MaintenanceTypesTransformer
{
public function transformMaintenanceTypes(Collection $types, int $total): array
{
$array = [];
foreach ($types as $type) {
$array[] = self::transformMaintenanceType($type);
}
return (new DatatablesTransformer)->transformDatatables($array, $total);
}
public function transformMaintenanceType(MaintenanceType $type): array
{
return [
'id' => (int) $type->id,
'name' => e($type->name),
'created_at' => Helper::getFormattedDateObject($type->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($type->updated_at, 'datetime'),
'deleted_at' => Helper::getFormattedDateObject($type->deleted_at, 'datetime'),
'available_actions' => [
'update' => Gate::allows('update', $type),
'delete' => $type->isDeletable(),
'restore' => Gate::allows('delete', $type),
],
];
}
}
@@ -82,22 +82,6 @@ class MaintenancesTransformer
'id' => (int) $assetmaintenance->adminuser->id,
'name' => e($assetmaintenance->adminuser->display_name),
] : null,
'maintenance_type' => $assetmaintenance->maintenanceType
? e($assetmaintenance->maintenanceType->name)
: null,
'responsible_party' => ($assetmaintenance->responsibleParty) ? [
'id' => (int) $assetmaintenance->responsibleParty->id,
'name' => e($assetmaintenance->responsibleParty->display_name),
] : null,
'checked_out_to_at_creation' => $assetmaintenance->checked_out_to_id ? [
'id' => (int) $assetmaintenance->checked_out_to_id,
'type' => $assetmaintenance->checked_out_to_type,
] : null,
'completed_at' => Helper::getFormattedDateObject($assetmaintenance->completed_at, 'datetime'),
'completed_by' => ($assetmaintenance->completedByUser) ? [
'id' => (int) $assetmaintenance->completedByUser->id,
'name' => e($assetmaintenance->completedByUser->display_name),
] : null,
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
'is_warranty' => (bool) $assetmaintenance->is_warranty,
@@ -107,7 +91,6 @@ class MaintenancesTransformer
$permissions_array['available_actions'] = [
'update' => (Gate::allows('update', Asset::class) && ((($assetmaintenance->asset) && $assetmaintenance->asset->deleted_at == ''))) ? true : false,
'delete' => Gate::allows('delete', Asset::class),
'complete' => Gate::allows('update', Asset::class) && ! $assetmaintenance->completed_at,
];
$array += $permissions_array;
@@ -145,23 +128,10 @@ class MaintenancesTransformer
'supplier' => ($assetmaintenance->supplier) ? e($assetmaintenance->supplier?->name) : null,
'url' => ($assetmaintenance->url) ? e($assetmaintenance->url) : null,
'cost' => Helper::formatCurrencyOutput($assetmaintenance->cost),
'maintenance_type' => $assetmaintenance->maintenanceType
? e($assetmaintenance->maintenanceType->name)
: null,
'asset_maintenance_type' => e($assetmaintenance->asset_maintenance_type),
'start_date' => Helper::getFormattedDateObject($assetmaintenance->start_date, 'date'),
'asset_maintenance_time' => $assetmaintenance->asset_maintenance_time,
'completion_date' => Helper::getFormattedDateObject($assetmaintenance->completion_date, 'date'),
'responsible_party' => ($assetmaintenance->responsibleParty) ? [
'id' => (int) $assetmaintenance->responsibleParty->id,
'name' => e($assetmaintenance->responsibleParty->display_name),
] : null,
'checked_out_to_at_creation' => ($assetmaintenance->checkedOutTo) ? e($assetmaintenance->checkedOutTo->display_name) : null,
'completed_at' => Helper::getFormattedDateObject($assetmaintenance->completed_at, 'datetime'),
'completed_by' => ($assetmaintenance->completedByUser) ? [
'id' => (int) $assetmaintenance->completedByUser->id,
'name' => e($assetmaintenance->completedByUser->display_name),
] : null,
'created_by' => ($assetmaintenance->adminuser) ? e($assetmaintenance->adminuser->display_name) : null,
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
@@ -52,9 +52,6 @@ class ManufacturersTransformer
'update' => (($manufacturer->deleted_at == '') && (Gate::allows('update', Manufacturer::class))),
'restore' => (($manufacturer->deleted_at != '') && (Gate::allows('create', Manufacturer::class))),
'delete' => $manufacturer->isDeletable(),
'bulk_selectable' => [
'delete' => $manufacturer->isDeletable(),
],
];
$array += $permissions_array;
@@ -57,9 +57,6 @@ class SuppliersTransformer
$permissions_array['available_actions'] = [
'update' => Gate::allows('update', Supplier::class),
'delete' => (Gate::allows('delete', Supplier::class) && ($supplier->isDeletable())),
'bulk_selectable' => [
'delete' => (Gate::allows('delete', Supplier::class) && ($supplier->isDeletable())),
],
];
$array += $permissions_array;
+4 -15
View File
@@ -82,17 +82,11 @@ class UsersTransformer
'consumables_count' => (int) $user->consumables_count,
'manages_users_count' => (int) $user->manages_users_count,
'manages_locations_count' => (int) $user->manages_locations_count,
// Legacy field — kept for backward API compatibility; use `companies` for multi-company support.
'company' => $user->companies->isNotEmpty() ? [
'id' => (int) $user->companies->first()->id,
'name' => e($user->companies->first()->name),
'tag_color' => ($user->companies->first()->tag_color) ? e($user->companies->first()->tag_color) : null,
'company' => ($user->company) ? [
'id' => (int) $user->company->id,
'name' => e($user->company->name),
'tag_color' => ($user->company->tag_color) ? e($user->company->tag_color) : null,
] : null,
'companies' => $user->companies->map(fn ($c) => [
'id' => (int) $c->id,
'name' => e($c->name),
'tag_color' => $c->tag_color ? e($c->tag_color) : null,
])->values(),
'created_by' => ($user->createdBy) ? [
'id' => (int) $user->createdBy->id,
'name' => e($user->createdBy->display_name),
@@ -150,11 +144,6 @@ class UsersTransformer
'last_name' => e($user->last_name),
'username' => e($user->username),
'display_name' => e($user->display_name),
'companies' => $user->companies->map(fn ($c) => [
'id' => (int) $c->id,
'name' => e($c->name),
'tag_color' => $c->tag_color ? e($c->tag_color) : null,
])->values(),
'created_by' => $user->adminuser ? [
'id' => (int) $user->adminuser->id,
'name' => e($user->adminuser->present()->fullName),
+1 -2
View File
@@ -37,8 +37,7 @@ class AccessoryImporter extends ItemImporter
$this->log('Updating Accessory');
$this->item['model_number'] = trim($this->findCsvMatch($row, 'model_number'));
$accessory->update($this->sanitizeItemForUpdating($accessory));
// update() already saves the model, no need to call save() again while Model::unguard() is active
$accessory->setImported(true);
$accessory->save();
return;
}
+8 -28
View File
@@ -176,55 +176,35 @@ class AssetImporter extends ItemImporter
if ($editingAsset) {
$asset->update($item);
$asset->setImported(true);
} else {
$asset->fill($item);
$asset->setImported(true);
}
// If we're updating, we don't want to overwrite old fields.
// Apply custom fields to asset attributes if they exist
$customFieldsToSave = [];
if (array_key_exists('custom_fields', $this->item)) {
foreach ($this->item['custom_fields'] as $custom_field => $val) {
$asset->{$custom_field} = $val;
$customFieldsToSave[$custom_field] = $val;
}
}
// For existing assets that have custom fields, update them.
// This avoids the issue of calling save() twice with Model::unguard() active.
if ($editingAsset && ! empty($customFieldsToSave)) {
$asset->update($customFieldsToSave);
$success = true;
} elseif (! $editingAsset) {
// For new assets, save with all changes (custom fields included via direct attribute assignment above)
$success = $asset->save();
} else {
// For existing assets without custom fields, update() already saved everything
$success = true;
}
// This sets an attribute on the Loggable trait for the action log
$asset->setImported(true);
if ($success) {
if ($asset->save()) {
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' created or updated');
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
// If we have a target to checkout to, lets do so.
// -- created_by is a property of the abstract class Importer, which this class inherits from and it's set by
// -- the class that needs to use it (command importer or GUI importer inside the project).
if (isset($target) && ($target !== false)) {
$asset = $asset->fresh();
$targetType = get_class($target);
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
// Skip duplicate checkout noise when update mode keeps the same assignment target.
if (! $alreadyCheckedOutToTarget) {
if (! is_null($asset->assigned_to)) {
if (! is_null($asset->assigned_to)) {
if ($asset->assigned_to != $target->id) {
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
}
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
}
$asset->fresh()->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
}
return;
+1 -2
View File
@@ -42,8 +42,7 @@ class ComponentImporter extends ItemImporter
}
$this->log('Updating Component');
$component->update($this->sanitizeItemForUpdating($component));
// update() already saves the model, no need to call save() again while Model::unguard() is active
$component->setImported(true);
$component->save();
return;
}
+1 -2
View File
@@ -38,8 +38,7 @@ class ConsumableImporter extends ItemImporter
}
$this->log('Updating Consumable');
$consumable->update($this->sanitizeItemForUpdating($consumable));
// update() already saves the model, no need to call save() again while Model::unguard() is active
$consumable->setImported(true);
$consumable->save();
return;
}
+2 -6
View File
@@ -88,12 +88,8 @@ class LicenseImporter extends ItemImporter
// This sets an attribute on the Loggable trait for the action log
$license->setImported(true);
// For new licenses we need to save, for existing ones update() already saved
$licenseWasSaved = $editingLicense || $license->save();
if ($licenseWasSaved) {
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created or updated');
if ($license->save()) {
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
// Lets try to checkout seats if the fields exist and we have seats.
if ($license->seats > 0) {
+5 -63
View File
@@ -3,7 +3,6 @@
namespace App\Importer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Department;
use App\Models\Setting;
use App\Models\User;
@@ -36,31 +35,6 @@ class UserImporter extends ItemImporter
$this->createUserIfNotExists($row);
}
/**
* Parse a pipe-separated company column value into an array of company IDs,
* creating companies that do not yet exist. Returns an empty array when the
* raw value is blank (so callers can treat that as "don't change").
*
* @param string $raw Raw cell value, e.g. "Acme Corp|Widget Inc"
* @return int[]
*/
private function resolveCompanyIds(string $raw): array
{
if ($raw === '') {
return [];
}
$ids = [];
foreach (array_filter(array_map('trim', explode('|', $raw))) as $name) {
$id = $this->createOrFetchCompany($name);
if ($id) {
$ids[] = (int) $id;
}
}
return Company::getIdsForCurrentUser($ids);
}
/**
* Create a user if a duplicate does not exist.
*
@@ -106,13 +80,6 @@ class UserImporter extends ItemImporter
$this->item['department_id'] = $this->createOrFetchDepartment($user_department);
}
// Resolve pipe-separated company names (e.g. "Acme Corp|Widget Inc") into IDs.
// company_id is a legacy column — company membership is managed via the pivot.
// Unset whatever the parent set so it is not written to the DB.
$companyRaw = trim($this->findCsvMatch($row, 'company'));
$companyIds = $this->resolveCompanyIds($companyRaw);
unset($this->item['company_id']);
if (is_null($this->item['username']) || $this->item['username'] == '') {
$user_full_name = $this->item['first_name'].' '.$this->item['last_name'];
$user_formatted_array = User::generateFormattedNameFromFullName($user_full_name, Setting::getSettings()->username_format);
@@ -137,13 +104,11 @@ class UserImporter extends ItemImporter
$this->log('Updating User');
// CLI imports run unauthenticated and are fully trusted; only restrict web-initiated imports.
// Note: unset must target $this->item, not the model — sanitizeItemForUpdating() reads from $this->item.
if (Auth::check() && (! Auth::user()->hasAccess('users.edit') || ! Gate::allows('canEditAuthFields', $user))) {
unset($this->item['username']);
unset($this->item['email']);
unset($this->item['password']);
unset($this->item['activated']);
if (Auth::check() && (! Gate::allows('canEditAuthFields', $user))) {
unset($user->username);
unset($user->email);
unset($user->password);
unset($user->activated);
}
$user->update($this->sanitizeItemForUpdating($user));
@@ -151,11 +116,6 @@ class UserImporter extends ItemImporter
// Why do we have to do this twice? Update should
$user->save();
// Sync company pivot when companies were specified in this row.
if (! empty($companyIds)) {
$user->companies()->sync($companyIds);
}
// Update the location of any assets checked out to this user
Asset::where('assigned_type', User::class)
->where('assigned_to', $user->id)
@@ -165,17 +125,6 @@ class UserImporter extends ItemImporter
return;
}
// With FMCS enabled, the scoped lookup above only sees users in the current user's companies.
// If the username exists in another company it would appear as "not found" and fall through
// to create — but usernames are unique system-wide, so we must skip instead.
if (Auth::check() && Company::isFullMultipleCompanySupportEnabled()) {
if (User::withoutGlobalScopes()->where('username', $this->item['username'])->exists()) {
$this->log('Skipping '.$this->item['username'].': username belongs to a user outside your company scope.');
return;
}
}
// This needs to be applied after the update logic, otherwise we'll overwrite user passwords
// Issue #5408
$this->item['password'] = $this->tempPassword;
@@ -191,13 +140,6 @@ class UserImporter extends ItemImporter
if ($user->save()) {
$this->log('User '.$this->item['name'].' was created');
// Sync all resolved companies to the pivot. For single-company rows the
// User::created event already added company_id; sync() here is idempotent
// for that case and adds any additional companies for multi-company rows.
if (! empty($companyIds)) {
$user->companies()->sync($companyIds);
}
if (($user->email) && ($user->activated == '1')) {
if ($this->send_welcome) {
-6
View File
@@ -720,12 +720,6 @@ class Importer extends Component
$this->message_type = 'danger';
}
public function process(): void
{
$this->message = trans('general.token_expired');
$this->message_type = 'danger';
}
public function clearMessage()
{
$this->message = null;
+28 -25
View File
@@ -6,7 +6,6 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Passport\Client;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\Token;
use Livewire\Component;
class OauthClients extends Component
@@ -51,11 +50,11 @@ class OauthClients extends Component
->get();
if ($clients->isNotEmpty()) {
$tokenCountsByClientId = Token::query()
$tokenCountsByClientId = DB::table('oauth_access_tokens')
->whereIn('client_id', $clients->pluck('id')->all())
->get(['client_id'])
->selectRaw('client_id, COUNT(*) as token_count')
->groupBy('client_id')
->map->count();
->pluck('token_count', 'client_id');
$clients->each(function ($client) use ($tokenCountsByClientId): void {
$client->setAttribute('associated_token_count', (int) ($tokenCountsByClientId[$client->id] ?? 0));
@@ -65,28 +64,32 @@ class OauthClients extends Component
$authorizedApplications = collect();
if ($this->showAuthorizedApplications()) {
$authorizedApplications = Token::query()
->where('revoked', false)
->with([
'client',
'client.user' => fn ($q) => $q->withTrashed(),
$authorizedTokenSummary = DB::table('oauth_access_tokens as tokens')
->where('tokens.revoked', false)
->selectRaw('tokens.client_id')
->selectRaw('MAX(tokens.scopes) as scopes')
->selectRaw('MAX(tokens.created_at) as created_at')
->selectRaw('MAX(tokens.expires_at) as expires_at')
->groupBy('tokens.client_id');
$authorizedApplications = DB::table('oauth_clients as clients')
->joinSub($authorizedTokenSummary, 'token_summary', function ($join) {
$join->on('clients.id', '=', 'token_summary.client_id');
})
->leftJoin('users as creators', 'clients.user_id', '=', 'creators.id')
->select([
'clients.id as client_id',
'clients.name as client_name',
'clients.user_id as client_owner_id',
'creators.display_name as client_owner_display_name',
'creators.username as client_owner_username',
'creators.deleted_at as client_owner_deleted_at',
'token_summary.scopes',
'token_summary.created_at',
'token_summary.expires_at',
])
->orderByDesc('created_at')
->get()
->unique('client_id')
->filter(fn ($token) => $token->client !== null)
->map(fn ($token) => (object) [
'client_id' => $token->client_id,
'client_name' => $token->client->name,
'client_owner_id' => $token->client->user_id,
'client_owner_display_name' => $token->client->user?->display_name,
'client_owner_username' => $token->client->user?->username,
'client_owner_deleted_at' => $token->client->user?->deleted_at,
'scopes' => $token->scopes,
'created_at' => $token->created_at,
'expires_at' => $token->expires_at,
])
->values();
->orderByDesc('token_summary.created_at')
->get();
}
return view('livewire.oauth-clients', [
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('audit_location')]
#[Title('Audit Location')]
#[Description('Review all assets at a location, flag overdue audits and status anomalies')]
class AuditLocationPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$location = $request->get('location');
$prompt = <<<TEXT
You are conducting an asset audit for location: {$location}.
Please complete the following steps using the available tools:
1. Find the location record for "{$location}" (search by name if needed).
2. List all assets currently assigned to or located at that location.
3. Identify any assets with overdue audit dates (next_audit_date is in the past).
4. Flag any assets with unexpected status labels (e.g. archived, pending, or out-for-repair assets that appear to still be at this location).
5. Note any assets that have been at this location longer than expected without a check-in or audit event.
6. Produce a summary report with: total asset count, assets requiring audit, assets with status anomalies, and any recommended actions.
Present the findings clearly so they can be acted on or exported.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('location', 'Name or ID of the location to audit', required: true),
];
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('end_of_life_review')]
#[Title('End of Life Review')]
#[Description('Identify assets that have passed their EOL date or are fully depreciated, and recommend disposition actions')]
class EndOfLifeReviewPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$department = $request->get('department');
$category = $request->get('category');
$scope = collect([
$department ? "department: {$department}" : null,
$category ? "category: {$category}" : null,
])->filter()->implode(' and ');
$scopeLine = $scope
? "Limit the review to assets in {$scope}."
: 'Review assets across the entire organisation.';
$prompt = <<<TEXT
You are conducting an end-of-life and depreciation review. {$scopeLine}
Please complete the following steps using the available tools:
1. List assets that have passed their asset_eol_date (end-of-life date is in the past).
2. List assets that are fully depreciated based on their depreciation schedule and purchase date.
3. For each identified asset, show: asset tag, name, model, assigned user or location, EOL date, purchase date, and current status.
4. Group findings by category for easier review.
5. Recommend disposition for each group: retire and replace, redeploy to a lower-demand role, send for repair, or archive.
6. Provide a cost summary if purchase cost data is available total value of end-of-life assets.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('department', 'Limit review to a specific department', required: false),
new Argument('category', 'Limit review to a specific asset category', required: false),
];
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('expiring_licenses')]
#[Title('Expiring Licenses')]
#[Description('Review license seat usage and flag licenses expiring within a given number of days')]
class ExpiringLicensesPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$days = (int) ($request->get('days', 30));
$prompt = <<<TEXT
You are reviewing software license health across the organisation. Focus on licenses expiring within {$days} days.
Please complete the following steps using the available tools:
1. List all licenses in the system.
2. Identify licenses whose expiration date falls within the next {$days} days.
3. For each expiring license, show: license name, total seats, seats in use, seats free, and the expiration date.
4. Flag any licenses that are over-deployed (more seats checked out than purchased).
5. Flag any licenses that are under-used (many free seats that may indicate unused subscriptions worth cancelling).
6. Produce a prioritised action list: renewals needed urgently, over-deployments to resolve, and possible cancellations.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('days', 'Number of days ahead to check for expiring licenses (default: 30)', required: false),
];
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('find_available_asset')]
#[Title('Find Available Asset')]
#[Description('Find an undeployed asset by category or model and optionally check it out to a user')]
class FindAvailableAssetPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$category = $request->get('category');
$model = $request->get('model');
$assignTo = $request->get('assign_to');
$assetDescription = collect([
$category ? "category: {$category}" : null,
$model ? "model: {$model}" : null,
])->filter()->implode(' / ');
$assignLine = $assignTo
? "If a suitable asset is found, check it out to the user: {$assignTo}."
: 'Ask whether the found asset should be checked out to a specific user before proceeding.';
$prompt = <<<TEXT
You need to find an available (undeployed) asset matching {$assetDescription}.
Please complete the following steps using the available tools:
1. Search for assets with a Ready-to-Deploy status that match the requested {$assetDescription}.
2. If multiple options are available, list them with their asset tags, serial numbers, and any relevant details so the best one can be selected.
3. {$assignLine}
4. Confirm the final asset tag, serial number, and checkout status once complete.
If no available assets match, report what was found and suggest alternatives (different models in the same category, or assets currently out for repair that may return soon).
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('category', 'Asset category to search (e.g. Laptop, Monitor)', required: false),
new Argument('model', 'Specific model name to search for', required: false),
new Argument('assign_to', 'Username to check the asset out to once found', required: false),
];
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('inventory_summary')]
#[Title('Inventory Summary')]
#[Description('Produce a high-level inventory count by category, broken down by deployment status')]
class InventorySummaryPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$location = $request->get('location');
$department = $request->get('department');
$scope = collect([
$location ? "location: {$location}" : null,
$department ? "department: {$department}" : null,
])->filter()->implode(' and ');
$scopeLine = $scope
? "Scope the report to {$scope}."
: 'Report across the entire organisation.';
$prompt = <<<TEXT
You are generating an inventory summary report. {$scopeLine}
Please complete the following steps using the available tools:
1. List assets (filtered by the scope above if provided) and tally counts by status: Deployed, Ready to Deploy, Archived, Pending, Out for Repair.
2. Break the deployed count down by asset category (laptops, monitors, phones, etc.).
3. List the top 5 models by total quantity.
4. Show total purchase value of the inventory if cost data is available.
5. Highlight any categories with zero available (Ready to Deploy) assets potential stock-out risk.
6. Present the results as a concise executive summary with a supporting breakdown table.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('location', 'Limit report to a specific location', required: false),
new Argument('department', 'Limit report to a specific department', required: false),
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('offboard_employee')]
#[Title('Offboard Employee')]
#[Description('Guide through checking in all equipment and licenses from a departing employee and deactivating their account')]
class OffboardEmployeePrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$username = $request->get('username');
$prompt = <<<TEXT
You are helping offboard a departing employee with username: {$username}.
Please complete the following offboarding steps using the available tools:
1. Look up the user account for {$username} and display a summary of everything currently assigned to them (assets, licenses, accessories, consumables).
2. Check in all assigned assets from this user.
3. Check in all assigned accessories from this user.
4. Revoke or check in any license seats assigned to this user.
5. Deactivate the user account.
6. Provide a final summary of all items that were checked in and confirm the account has been deactivated.
If any items cannot be checked in automatically, flag them for manual follow-up.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('username', 'Username of the departing employee', required: true),
];
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('onboard_employee')]
#[Title('Onboard Employee')]
#[Description('Guide through creating a new employee account and assigning appropriate equipment and licenses')]
class OnboardEmployeePrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$firstName = $request->get('first_name');
$lastName = $request->get('last_name');
$department = $request->get('department');
$location = $request->get('location');
$title = $request->get('title');
$fullName = trim("{$firstName} {$lastName}");
$context = collect([
$department ? "Department: {$department}" : null,
$location ? "Location: {$location}" : null,
$title ? "Job title: {$title}" : null,
])->filter()->implode("\n");
$prompt = <<<TEXT
You are helping onboard a new employee.
Employee details:
- First name: {$firstName}
- Last name: {$lastName}
{$context}
Please complete the following onboarding steps using the available tools:
1. Create a new user account using first_name "{$firstName}" and last_name "{$lastName}" along with the details provided above. Ask for any missing required fields (username and, optionally, email address) before proceeding. Do not ask for a password one will be set automatically.
2. If the new account has an email address, ask whether you should send them a password reset link so they can set their own password. Use send_password_reset if the answer is yes.
3. Search for available (undeployed) assets suitable for their role typically a laptop and any other standard equipment for their department or location.
4. Check out the selected assets to the new user.
5. Check whether any software license seats are available that should be assigned (e.g. productivity suites, VPN, etc.) and assign them.
6. Summarise what was set up: the user account created, whether a password reset email was sent, assets checked out, and licenses assigned.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('first_name', 'First name of the new employee', required: true),
new Argument('last_name', 'Last name of the new employee', required: false),
new Argument('department', 'Department the employee will join', required: false),
new Argument('location', 'Primary office location', required: false),
new Argument('title', 'Job title', required: false),
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Server\Prompt;
abstract class SnipePrompt extends Prompt
{
/**
* Returns a trailing instruction telling the model which language to respond in,
* derived from the authenticated user's locale setting. Returns an empty string
* for English locales so the prompt text is unchanged for the majority of users.
*/
protected function localeInstruction(): string
{
$locale = auth()->user()?->locale ?? app()->getLocale();
if (str_starts_with($locale, 'en')) {
return '';
}
return "\n\nPlease respond in the language that corresponds to locale: {$locale}.";
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('user_inventory')]
#[Title('User Inventory')]
#[Description('List everything currently assigned to a specific user across all asset types')]
class UserInventoryPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$username = $request->get('username');
$prompt = <<<TEXT
You are pulling a complete inventory of everything assigned to the user: {$username}.
Please complete the following steps using the available tools:
1. Look up the user account for {$username} and display their basic info (name, department, location, job title).
2. List all assets currently checked out to this user (asset tag, name, model, serial, status).
3. List all accessories checked out to this user.
4. List all license seats assigned to this user.
5. List any consumables that have been checked out to this user.
6. Calculate the total purchase value of all assigned assets if cost data is available.
7. Present a clean summary grouped by item type, suitable for sharing with a manager or for an audit.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('username', 'Username of the user to review', required: true),
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('warranty_expiring')]
#[Title('Warranty Expiring')]
#[Description('List assets whose warranty expires within a given number of days')]
class WarrantyExpiringPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$days = (int) ($request->get('days', 90));
$prompt = <<<TEXT
You are reviewing assets whose warranty is expiring soon. Focus on assets expiring within {$days} days.
Please complete the following steps using the available tools:
1. List assets and filter for those whose warranty expiration date (calculated from purchase_date + warranty_months) falls within the next {$days} days.
2. For each asset, show: asset tag, name, model, assigned user or location, purchase date, warranty months, and calculated warranty end date.
3. Group by urgency: expiring within 30 days, 3160 days, and 61{$days} days.
4. Flag any assets that are deployed to critical roles or users where warranty coverage is especially important.
5. Recommend actions: extend warranty, schedule replacement, or note as acceptable risk.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('days', 'Number of days ahead to check for warranty expiry (default: 90)', required: false),
];
}
}
+1066
View File
File diff suppressed because it is too large Load Diff
+275
View File
@@ -0,0 +1,275 @@
<?php
namespace App\Mcp\Servers;
use App\Mcp\Prompts\AuditLocationPrompt;
use App\Mcp\Prompts\EndOfLifeReviewPrompt;
use App\Mcp\Prompts\ExpiringLicensesPrompt;
use App\Mcp\Prompts\FindAvailableAssetPrompt;
use App\Mcp\Prompts\InventorySummaryPrompt;
use App\Mcp\Prompts\OffboardEmployeePrompt;
use App\Mcp\Prompts\OnboardEmployeePrompt;
use App\Mcp\Prompts\UserInventoryPrompt;
use App\Mcp\Prompts\WarrantyExpiringPrompt;
use App\Mcp\Tools\AddAssetNoteTool;
use App\Mcp\Tools\AuditAssetTool;
use App\Mcp\Tools\CheckinAccessoryTool;
use App\Mcp\Tools\CheckinAssetTool;
use App\Mcp\Tools\CheckinComponentTool;
use App\Mcp\Tools\CheckinLicenseTool;
use App\Mcp\Tools\CheckoutAccessoryTool;
use App\Mcp\Tools\CheckoutAssetTool;
use App\Mcp\Tools\CheckoutComponentTool;
use App\Mcp\Tools\CheckoutConsumableTool;
use App\Mcp\Tools\CheckoutLicenseTool;
use App\Mcp\Tools\CreateAccessoryTool;
use App\Mcp\Tools\CreateAssetModelTool;
use App\Mcp\Tools\CreateAssetTool;
use App\Mcp\Tools\CreateCategoryTool;
use App\Mcp\Tools\CreateCompanyTool;
use App\Mcp\Tools\CreateComponentTool;
use App\Mcp\Tools\CreateConsumableTool;
use App\Mcp\Tools\CreateDepartmentTool;
use App\Mcp\Tools\CreateDepreciationTool;
use App\Mcp\Tools\CreateGroupTool;
use App\Mcp\Tools\CreateLicenseTool;
use App\Mcp\Tools\CreateLocationTool;
use App\Mcp\Tools\CreateMaintenanceTool;
use App\Mcp\Tools\CreateManufacturerTool;
use App\Mcp\Tools\CreateStatusLabelTool;
use App\Mcp\Tools\CreateSupplierTool;
use App\Mcp\Tools\CreateUserTool;
use App\Mcp\Tools\DeleteAccessoryTool;
use App\Mcp\Tools\DeleteAssetModelTool;
use App\Mcp\Tools\DeleteAssetTool;
use App\Mcp\Tools\DeleteCategoryTool;
use App\Mcp\Tools\DeleteCompanyTool;
use App\Mcp\Tools\DeleteComponentTool;
use App\Mcp\Tools\DeleteConsumableTool;
use App\Mcp\Tools\DeleteDepartmentTool;
use App\Mcp\Tools\DeleteDepreciationTool;
use App\Mcp\Tools\DeleteGroupTool;
use App\Mcp\Tools\DeleteLicenseTool;
use App\Mcp\Tools\DeleteLocationTool;
use App\Mcp\Tools\DeleteManufacturerTool;
use App\Mcp\Tools\DeleteStatusLabelTool;
use App\Mcp\Tools\DeleteSupplierTool;
use App\Mcp\Tools\DeleteUserTool;
use App\Mcp\Tools\GetActivityLogTool;
use App\Mcp\Tools\GetCurrentUserTool;
use App\Mcp\Tools\GetUserAssetsTool;
use App\Mcp\Tools\ListAssetModelsTool;
use App\Mcp\Tools\ListAssetNotesTool;
use App\Mcp\Tools\ListAssetsTool;
use App\Mcp\Tools\ListCategoriesTool;
use App\Mcp\Tools\ListCompaniesTool;
use App\Mcp\Tools\ListConsumablesTool;
use App\Mcp\Tools\ListDepreciationsTool;
use App\Mcp\Tools\ListGroupsTool;
use App\Mcp\Tools\ListHistoryTool;
use App\Mcp\Tools\ListLicensesTool;
use App\Mcp\Tools\ListLocationsTool;
use App\Mcp\Tools\ListMaintenancesTool;
use App\Mcp\Tools\ListManufacturersTool;
use App\Mcp\Tools\ListStatusLabelsTool;
use App\Mcp\Tools\ListSuppliersTool;
use App\Mcp\Tools\ListUploadsTool;
use App\Mcp\Tools\ListUsersTool;
use App\Mcp\Tools\Reset2FATool;
use App\Mcp\Tools\RestoreAssetTool;
use App\Mcp\Tools\RestoreUserTool;
use App\Mcp\Tools\SendPasswordResetTool;
use App\Mcp\Tools\ShowAssetModelTool;
use App\Mcp\Tools\ShowAssetTool;
use App\Mcp\Tools\ShowCategoryTool;
use App\Mcp\Tools\ShowCompanyTool;
use App\Mcp\Tools\ShowConsumableTool;
use App\Mcp\Tools\ShowDepreciationTool;
use App\Mcp\Tools\ShowGroupTool;
use App\Mcp\Tools\ShowLicenseTool;
use App\Mcp\Tools\ShowLocationTool;
use App\Mcp\Tools\ShowManufacturerTool;
use App\Mcp\Tools\ShowStatusLabelTool;
use App\Mcp\Tools\ShowSupplierTool;
use App\Mcp\Tools\ShowUserTool;
use App\Mcp\Tools\UpdateAccessoryTool;
use App\Mcp\Tools\UpdateAssetModelTool;
use App\Mcp\Tools\UpdateAssetTool;
use App\Mcp\Tools\UpdateCategoryTool;
use App\Mcp\Tools\UpdateCompanyTool;
use App\Mcp\Tools\UpdateComponentTool;
use App\Mcp\Tools\UpdateConsumableTool;
use App\Mcp\Tools\UpdateDepartmentTool;
use App\Mcp\Tools\UpdateDepreciationTool;
use App\Mcp\Tools\UpdateGroupTool;
use App\Mcp\Tools\UpdateLicenseTool;
use App\Mcp\Tools\UpdateLocationTool;
use App\Mcp\Tools\UpdateManufacturerTool;
use App\Mcp\Tools\UpdateProfileTool;
use App\Mcp\Tools\UpdateStatusLabelTool;
use App\Mcp\Tools\UpdateSupplierTool;
use App\Mcp\Tools\UpdateUserTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('Snipe-IT MCP Server')]
#[Version('0.0.1')]
#[Instructions('This server allows you to interact with the Snipe-IT asset management database. You can list, view, check out, and check in assets.')]
class SnipeMCPServer extends Server
{
protected array $tools = [
// Assets
ShowAssetTool::class,
ListAssetsTool::class,
CreateAssetTool::class,
UpdateAssetTool::class,
DeleteAssetTool::class,
RestoreAssetTool::class,
CheckoutAssetTool::class,
CheckinAssetTool::class,
AuditAssetTool::class,
AddAssetNoteTool::class,
ListAssetNotesTool::class,
// Cross-type tools
ListUploadsTool::class,
ListHistoryTool::class,
// Users
ListUsersTool::class,
ShowUserTool::class,
CreateUserTool::class,
UpdateUserTool::class,
DeleteUserTool::class,
RestoreUserTool::class,
GetCurrentUserTool::class,
UpdateProfileTool::class,
GetUserAssetsTool::class,
Reset2FATool::class,
SendPasswordResetTool::class,
// Accessories
CreateAccessoryTool::class,
UpdateAccessoryTool::class,
DeleteAccessoryTool::class,
CheckoutAccessoryTool::class,
CheckinAccessoryTool::class,
// Components
CreateComponentTool::class,
UpdateComponentTool::class,
DeleteComponentTool::class,
CheckoutComponentTool::class,
CheckinComponentTool::class,
// Consumables
ListConsumablesTool::class,
ShowConsumableTool::class,
CreateConsumableTool::class,
UpdateConsumableTool::class,
DeleteConsumableTool::class,
CheckoutConsumableTool::class,
// Licenses
ListLicensesTool::class,
ShowLicenseTool::class,
CreateLicenseTool::class,
UpdateLicenseTool::class,
DeleteLicenseTool::class,
CheckoutLicenseTool::class,
CheckinLicenseTool::class,
// Departments
CreateDepartmentTool::class,
UpdateDepartmentTool::class,
DeleteDepartmentTool::class,
// Companies
ListCompaniesTool::class,
ShowCompanyTool::class,
CreateCompanyTool::class,
UpdateCompanyTool::class,
DeleteCompanyTool::class,
// Categories
ListCategoriesTool::class,
ShowCategoryTool::class,
CreateCategoryTool::class,
UpdateCategoryTool::class,
DeleteCategoryTool::class,
// Manufacturers
ListManufacturersTool::class,
ShowManufacturerTool::class,
CreateManufacturerTool::class,
UpdateManufacturerTool::class,
DeleteManufacturerTool::class,
// Suppliers
ListSuppliersTool::class,
ShowSupplierTool::class,
CreateSupplierTool::class,
UpdateSupplierTool::class,
DeleteSupplierTool::class,
// Status Labels
ListStatusLabelsTool::class,
ShowStatusLabelTool::class,
CreateStatusLabelTool::class,
UpdateStatusLabelTool::class,
DeleteStatusLabelTool::class,
// Locations
ListLocationsTool::class,
ShowLocationTool::class,
CreateLocationTool::class,
UpdateLocationTool::class,
DeleteLocationTool::class,
// Asset Models
ListAssetModelsTool::class,
ShowAssetModelTool::class,
CreateAssetModelTool::class,
UpdateAssetModelTool::class,
DeleteAssetModelTool::class,
// Depreciations
ListDepreciationsTool::class,
ShowDepreciationTool::class,
CreateDepreciationTool::class,
UpdateDepreciationTool::class,
DeleteDepreciationTool::class,
// Groups
ListGroupsTool::class,
ShowGroupTool::class,
CreateGroupTool::class,
UpdateGroupTool::class,
DeleteGroupTool::class,
// Maintenance
ListMaintenancesTool::class,
CreateMaintenanceTool::class,
// Activity Log
GetActivityLogTool::class,
];
protected array $resources = [
//
];
protected array $prompts = [
OnboardEmployeePrompt::class,
OffboardEmployeePrompt::class,
AuditLocationPrompt::class,
FindAvailableAssetPrompt::class,
ExpiringLicensesPrompt::class,
EndOfLifeReviewPrompt::class,
WarrantyExpiringPrompt::class,
InventorySummaryPrompt::class,
UserInventoryPrompt::class,
];
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Actionlog;
use App\Models\Asset;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('add_asset_note')]
#[Title('Add Asset Note')]
#[Description('Add a manual note to a Snipe-IT asset identified by asset tag, serial number, or numeric ID')]
class AddAssetNoteTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'asset_tag' => 'nullable|string|max:100',
'serial' => 'nullable|string|max:255',
'id' => 'nullable|integer',
'note' => 'required|string|max:50000',
]);
$asset = $this->resolveAsset($request);
if (! $asset) {
return Response::make(Response::error(trans('mcp.asset_not_found')));
}
if (! Gate::allows('update', $asset)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$logaction = new Actionlog;
$logaction->item_type = Asset::class;
$logaction->item_id = $asset->id;
$logaction->note = $request->get('note');
$logaction->created_by = auth()->id();
if ($logaction->logaction('note added')) {
return Response::make(
Response::text(trans('mcp.note_added_to_asset', ['asset_tag' => $asset->asset_tag]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.note_added_successfully'),
'asset_tag' => $asset->asset_tag,
'asset_id' => $asset->id,
'note' => $logaction->note,
]);
}
return Response::make(Response::error(trans('mcp.note_save_failed')));
}
private function resolveAsset(Request $request): ?Asset
{
if ($request->filled('asset_tag')) {
return Asset::where('asset_tag', $request->get('asset_tag'))->first();
}
if ($request->filled('serial')) {
return Asset::where('serial', $request->get('serial'))->first();
}
if ($request->filled('id')) {
return Asset::find($request->get('id'));
}
return null;
}
public function schema(JsonSchema $schema): array
{
return [
'asset_tag' => $schema->string()->description('Asset tag of the asset'),
'serial' => $schema->string()->description('Serial number of the asset'),
'id' => $schema->number()->description('Numeric ID of the asset'),
'note' => $schema->string()->description('Note text to add to the asset'),
];
}
public function outputSchema(JsonSchema $schema): array
{
return [
'success' => $schema->boolean()->description('True if the note was saved'),
'message' => $schema->string()->description('Human-readable result message')->required(),
'asset_tag' => $schema->string()->description('Asset tag of the asset'),
'asset_id' => $schema->number()->description('Numeric ID of the asset'),
'note' => $schema->string()->description('The note that was saved'),
];
}
}

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