Compare commits
474 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90c8689596 | |||
| 161d7e1c2b | |||
| 8627032c4f | |||
| 5bead4fbcc | |||
| ef44ba5f97 | |||
| 2dc0ec9e7e | |||
| afd435e895 | |||
| 80d1bf6a7a | |||
| 737f3ef3db | |||
| d179f47274 | |||
| 1832d95371 | |||
| a614f986f0 | |||
| f398a59d26 | |||
| c8bd104268 | |||
| 7f01bd4c56 | |||
| 6c3c7fdf49 | |||
| bdc8fc8d4a | |||
| 41be127489 | |||
| fdfae9593d | |||
| 6a21eb53c9 | |||
| efde2b4672 | |||
| daaa26cbf4 | |||
| 3e7441562c | |||
| 7b53fa5245 | |||
| d35d46f5b4 | |||
| f4772a9cad | |||
| 4c1bb7e0ac | |||
| 762ea9b4db | |||
| b16970a61e | |||
| dadb9bd81e | |||
| 13dc7de660 | |||
| 003ea36e18 | |||
| f4bd2a68c9 | |||
| be4e75d4f7 | |||
| 538c21ce1e | |||
| 626cd6cb2e | |||
| 2a56f6573d | |||
| 6ee2dc1cd6 | |||
| 3fcde8bd16 | |||
| e2ff7a7bc7 | |||
| c7efd16517 | |||
| f2907f04d9 | |||
| 7d98c267d5 | |||
| 5bc6330c13 | |||
| 1706ed597d | |||
| 6e9ba28ef7 | |||
| 554d1a44de | |||
| c0a8f4c1a4 | |||
| 08be9aac6d | |||
| a51b17fb53 | |||
| 66d5618d60 | |||
| e16c2384fd | |||
| b3323f08a0 | |||
| 7e63c2ef92 | |||
| 7f65b6d598 | |||
| 8fb8f0a4d2 | |||
| 637dbc8d2a | |||
| 978990fdff | |||
| 52a058e511 | |||
| 64bea202c5 | |||
| 37f60993ca | |||
| 32717c67c7 | |||
| 3681e3f025 | |||
| 1d0f055349 | |||
| fb3024ca9c | |||
| 005c0ea9f6 | |||
| 7c3f1f3a84 | |||
| 900e5209d9 | |||
| 4fbf416d16 | |||
| 7b7d2c87fb | |||
| 6debb3a65d | |||
| 315ba49a1d | |||
| ff57855038 | |||
| da6e837578 | |||
| a2d8f89162 | |||
| e36d65e695 | |||
| 34abf14cbe | |||
| dda7a4f22f | |||
| 283a885196 | |||
| d44aa3f16e | |||
| 575e825579 | |||
| dc8cbf4786 | |||
| 5f81a48d8b | |||
| c22e4c00a5 | |||
| afb37981bf | |||
| 9b5ead39d3 | |||
| 158e66f9c6 | |||
| bd8e944e2f | |||
| 2b6518427a | |||
| 06d95b679b | |||
| ff75b9eed8 | |||
| 17a88fcb80 | |||
| 185e0073b3 | |||
| eca34de593 | |||
| d0794ba71c | |||
| 40e89756bf | |||
| 55e46b2d15 | |||
| 02383aad7b | |||
| e75f54cc1c | |||
| 1b42e2e138 | |||
| 3668c24d02 | |||
| a84533b4f4 | |||
| b4efabe82e | |||
| cbe750cc9e | |||
| a77dedf3d7 | |||
| b6ce823cc2 | |||
| f7e8ce2ade | |||
| 62e5b71dc1 | |||
| 3d04324595 | |||
| 468cf73b97 | |||
| 5b90f9fb87 | |||
| 9131dbf09b | |||
| 9b37e95b58 | |||
| a425234365 | |||
| a92d8eeaab | |||
| cd4e268c72 | |||
| b94945a461 | |||
| 5b0a779c07 | |||
| e8dbb12ccc | |||
| d099bf2983 | |||
| f7add0e4dd | |||
| 1e1cc897ad | |||
| 04e2c59aa9 | |||
| 03bd3517be | |||
| eeba5bc8fd | |||
| 1f54180c9c | |||
| 8497a27c81 | |||
| 80afa470ee | |||
| 10c750e1a2 | |||
| 3aa175b36d | |||
| 8a2cd19ea6 | |||
| e76036965b | |||
| 2bb86a2ec1 | |||
| a89c8c6e5b | |||
| 1bdf205ca6 | |||
| afdf86ad0d | |||
| ccf801137a | |||
| ef746a173e | |||
| a5dae3f222 | |||
| e3552f4e36 | |||
| 75d9357488 | |||
| 26c028cf37 | |||
| 10c483967f | |||
| 07b33e8189 | |||
| 97765c08b1 | |||
| fc3ea78005 | |||
| 6ad92556a1 | |||
| bd4150af5a | |||
| e2465ca2a7 | |||
| 1c6c93da35 | |||
| 0daec32ddd | |||
| e466ed9e06 | |||
| 4445b0317f | |||
| beaea6c3bf | |||
| f5644928a8 | |||
| a279c44aa5 | |||
| f1f96e574c | |||
| 1879001ef3 | |||
| 5014b1c459 | |||
| 903459cf7e | |||
| 7c04661cfa | |||
| 76d3194c96 | |||
| b63aee2851 | |||
| f57d2608c5 | |||
| 34331525b1 | |||
| 8d1f4427ae | |||
| 7f89f8284f | |||
| 3b2ac2bc3c | |||
| 73e88be8f3 | |||
| f5d092f497 | |||
| 8edbad92cb | |||
| b0e13a1352 | |||
| 5c75648cd7 | |||
| 1872c6eed9 | |||
| 53199b9737 | |||
| 73861c6a04 | |||
| e2969dd3e2 | |||
| 0b1b99697e | |||
| 07202a8061 | |||
| 189454096b | |||
| 55ee5df852 | |||
| f6466b9154 | |||
| 8e5a64dca9 | |||
| b894147514 | |||
| d55c2c269f | |||
| c7afcf0bef | |||
| c79f5b8b74 | |||
| c5296fd76d | |||
| 3cb3284b26 | |||
| d5d0d00ecc | |||
| dc6b45cbcb | |||
| 5db9d67e65 | |||
| f64dfa7f92 | |||
| 06584d17a6 | |||
| 73bbe5062d | |||
| 75cb1041ec | |||
| b61ed66d9d | |||
| 48ebd7faf5 | |||
| d6de3baa6e | |||
| 1be44a4c05 | |||
| f17f34f730 | |||
| da5bb6126a | |||
| a5d04d2e65 | |||
| 22d07214fe | |||
| 8d4523d250 | |||
| 37a3d694d4 | |||
| d21ccdfcbf | |||
| 11eaf7ce7b | |||
| 4eba97d388 | |||
| 590e97a99f | |||
| 613137551a | |||
| 23f941c810 | |||
| 4c09f3a229 | |||
| e7312801ac | |||
| cd69a7ea53 | |||
| 13ffdda12e | |||
| 372e74aad3 | |||
| 2a32f7d372 | |||
| a2d34cca76 | |||
| c513ed5fc3 | |||
| 34cd5dcf7c | |||
| 260ca085bb | |||
| 7b00074b9e | |||
| 16e981d99d | |||
| 16eb899ba7 | |||
| 3367f8e5c7 | |||
| ad635ab95c | |||
| b94e7fd8a0 | |||
| 683fbd7953 | |||
| 246ec9e20b | |||
| 81d669d62a | |||
| 9ff951d379 | |||
| e327303b3c | |||
| 21d030db26 | |||
| 444b58504c | |||
| c1e2f4ad75 | |||
| ec6778e770 | |||
| 1c5d81cb04 | |||
| 10e6c93a95 | |||
| d37f43daba | |||
| dbabd1bab3 | |||
| 6b5398139a | |||
| 0ec45a4fd0 | |||
| b99fd237f3 | |||
| 5d7123eb05 | |||
| 7eb6ebb60d | |||
| 0060207816 | |||
| 5bc273686e | |||
| 2f6420e05f | |||
| c01699b6e4 | |||
| 6c6199add8 | |||
| 42cd5e0017 | |||
| baee6a37ea | |||
| 90b3685808 | |||
| 37a37318aa | |||
| 74e831c4f0 | |||
| be36390b0f | |||
| e9a628066f | |||
| 8f46b5254e | |||
| cf44119bc6 | |||
| a15e9d737c | |||
| 08f6f5cf71 | |||
| faa2adbde2 | |||
| 7fae60d5c3 | |||
| 4f9ce07304 | |||
| 3cad34821e | |||
| 1e4353f0db | |||
| 7520a1b2a3 | |||
| cdd91e498a | |||
| 2daf0458a7 | |||
| 6299fc09bf | |||
| 2327cc6866 | |||
| b235df0bbf | |||
| 6d56ab9b63 | |||
| ce5de8fe06 | |||
| ce3f80246e | |||
| 046ef82c65 | |||
| 0f347e8453 | |||
| 1cb0ca84ab | |||
| 7625646c11 | |||
| 743c598b83 | |||
| 324530fb8c | |||
| 68acf7b90a | |||
| 9c610f51af | |||
| 40ec0627c4 | |||
| 645e66b30c | |||
| 2311d56836 | |||
| 1f3481c54b | |||
| 07fa51aa4c | |||
| 0866469cc0 | |||
| 3d9bb29b1b | |||
| 5a67bcaf17 | |||
| 01b18513f1 | |||
| d92ec582fa | |||
| 205eb7fd37 | |||
| 0798e62417 | |||
| 83adcc61bc | |||
| 788e07947f | |||
| f7717571ea | |||
| 83fec75bc8 | |||
| 53c240f13f | |||
| f142eb7a44 | |||
| fe84d35ce4 | |||
| 5c5414c960 | |||
| 2eeb1f588a | |||
| 9f69eacf71 | |||
| 495382c42f | |||
| 029634707b | |||
| fd5736fac4 | |||
| f3ed2d9dd8 | |||
| 676cd66e4b | |||
| 17c73c4017 | |||
| 5983a4530f | |||
| 1cd8395b23 | |||
| ea4374a855 | |||
| 061b913413 | |||
| b2fda13ac3 | |||
| e79af0163a | |||
| 91e41049bd | |||
| 18f67bcce5 | |||
| 264347e323 | |||
| 18d9f7dbf1 | |||
| 702af91c84 | |||
| e9db3b3861 | |||
| 896922fde5 | |||
| 7c2bb69bc9 | |||
| d2921346a2 | |||
| cc06b2f0eb | |||
| d98c7bddba | |||
| fe308ef2e4 | |||
| 9b43835e2d | |||
| 88d34a5b92 | |||
| 019f0af282 | |||
| c6619a621c | |||
| 565e3de183 | |||
| b91dd15f96 | |||
| 1a852cdacf | |||
| 4ea527d980 | |||
| 392af4f127 | |||
| 6e8e72f281 | |||
| 1c198500c6 | |||
| 8620f25c0e | |||
| 1311ce48d3 | |||
| 8a59809937 | |||
| bac8299ea6 | |||
| 500dcdd582 | |||
| cbae494c54 | |||
| 09bec66406 | |||
| 2b2291dc7e | |||
| 1357b45e24 | |||
| 4a0dbba3ec | |||
| fcd0360135 | |||
| 6935cf1dde | |||
| cf384373df | |||
| 48c4f34af3 | |||
| 7b800152ee | |||
| f289691e22 | |||
| 50421494c5 | |||
| 33b8861ae3 | |||
| 31f90d20f8 | |||
| a94ba474f3 | |||
| a81ab0ea0f | |||
| 87e65893d3 | |||
| 405540aea2 | |||
| ccfebee5f1 | |||
| 1d9469a3df | |||
| 5417bf3445 | |||
| 51ea1327cf | |||
| 8113ddb2d5 | |||
| a88ad35b68 | |||
| 6e60f59265 | |||
| a866bfafcd | |||
| 97d1677568 | |||
| f4562db0c0 | |||
| a616da3e5c | |||
| a895566b02 | |||
| 5d75765aae | |||
| 3bc34fcd5e | |||
| 99c3ac56e9 | |||
| 95c7d5eeff | |||
| 371f0b82f6 | |||
| 114b5d3db0 | |||
| 9ab0f60b41 | |||
| 33b8226ebe | |||
| e062062cb3 | |||
| 5f713862fb | |||
| fe013b5ea0 | |||
| 57df2dc2cf | |||
| c86fa4c521 | |||
| c90acf53d5 | |||
| fece4d2fdc | |||
| f49837e5fe | |||
| e6ead7c6fa | |||
| 3e84af83d8 | |||
| aff375c799 | |||
| 6ada1a646f | |||
| 42bfd24a8f | |||
| c7c6b41dab | |||
| 449e6b5f5c | |||
| bbe0c7409f | |||
| 41bb9c378b | |||
| 9a82b890d4 | |||
| 2d2180c9e8 | |||
| 798a590c2a | |||
| 8a08878062 | |||
| fde6ff1571 | |||
| 8c9a48b38a | |||
| fff89ee94c | |||
| 2745552915 | |||
| 2f400a2b17 | |||
| de5256b8f5 | |||
| 344ae053cf | |||
| 45fffd74b7 | |||
| a0cf0751de | |||
| 7485cb81aa | |||
| 7faa9a6fdf | |||
| f6f7063419 | |||
| 1300fff94c | |||
| 5ef9798c68 | |||
| db48c18766 | |||
| d6b48a2818 | |||
| f8c7eee17b | |||
| c8d2118c74 | |||
| ddaa75a6dd | |||
| 182e06173d | |||
| f6b4600f8a | |||
| 6eaea0b73f | |||
| ee7dddf836 | |||
| e2e4743994 | |||
| 7b1a5aea19 | |||
| 602e13dab7 | |||
| 64117b92b0 | |||
| 17c89a3f2b | |||
| 9d33a2c524 | |||
| a470ba76df | |||
| 3ce017fa68 | |||
| d446da2243 | |||
| cdb4416421 | |||
| a1de8aa20c | |||
| adfad90f7c | |||
| 22703806cd | |||
| 22a63fc2ee | |||
| 8747ff32dd | |||
| 6145c6cc5a | |||
| 4ddd2f1cf8 | |||
| 11c8fd4d4c | |||
| ab04f3de93 | |||
| 4c16796256 | |||
| 516771d948 | |||
| e25ea465c5 | |||
| 30ac3d1a26 | |||
| e47c772230 | |||
| 706b623d95 | |||
| a908a76f53 | |||
| a2ec707f79 | |||
| 7cbc0fa671 | |||
| 15346eec22 | |||
| c48e0c7377 | |||
| 95fdfa6396 | |||
| f8ecbf8f0b | |||
| c5ffbf6ed9 | |||
| 2115de9926 | |||
| 53149666ad | |||
| 5d55c5021b | |||
| 778da511a5 | |||
| 84940f12c5 | |||
| 0f45ecc00f | |||
| fc4ac029b1 | |||
| 73f4afa05e | |||
| ef1a42fff2 | |||
| 760d089073 | |||
| 92fbf83bdb | |||
| 9525bbf502 | |||
| 61df3bc462 |
@@ -4262,6 +4262,15 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Husky-Devel",
|
||||
"name": "Peter Gallwas",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/75509373?v=4",
|
||||
"profile": "https://www.husky.nz",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+10
-1
@@ -90,7 +90,16 @@ IMAGE_LIB=gd
|
||||
|
||||
# --------------------------------------------
|
||||
# OPTIONAL: BACKUP SETTINGS
|
||||
# --------------------------------------------
|
||||
# Backup filesystem configuration
|
||||
# - BACKUP_FILESYSTEM_DRIVER: Driver to use (local, s3, etc.)
|
||||
# Default: local (backward compatible)
|
||||
# Set to s3 to use S3 for backups (requires PRIVATE_AWS_* credentials)
|
||||
# - BACKUP_FILESYSTEM_ROOT: Root path/prefix
|
||||
# For local driver: leave commented for default to storage_path("app")
|
||||
# For S3 driver: empty string = bucket root, or specify prefix like "backups/"
|
||||
#--------------------------------------------
|
||||
BACKUP_FILESYSTEM_DRIVER=local
|
||||
#BACKUP_FILESYSTEM_ROOT=
|
||||
MAIL_BACKUP_NOTIFICATION_DRIVER=null
|
||||
MAIL_BACKUP_NOTIFICATION_ADDRESS=null
|
||||
BACKUP_ENV=true
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
# EthicalCheck addresses the critical need to continuously security test APIs in development and in production.
|
||||
|
||||
# EthicalCheck provides the industry’s 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@v3
|
||||
with:
|
||||
sarif_file: ./ethicalcheck-results.sarif
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
- "8.2"
|
||||
- "8.3"
|
||||
- "8.4"
|
||||
- "8.5"
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
- "8.2"
|
||||
- "8.3"
|
||||
- "8.4"
|
||||
- "8.5"
|
||||
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-version:
|
||||
- "8.3"
|
||||
- "8.5"
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
|
||||
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
|
||||
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
|
||||
"DOC4": "You should really just ignore it and run upgrade.php. Really",
|
||||
"php_min_version": "8.2.0",
|
||||
"php_max_major_minor": "8.4",
|
||||
"php_max_wontwork": "8.5.0",
|
||||
"current_snipeit_version": "8.0"
|
||||
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
|
||||
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
|
||||
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
|
||||
"DOC4": "You should really just ignore it and run upgrade.php. Really",
|
||||
"php_min_version": "8.2.0",
|
||||
"php_max_major_minor": "8.5",
|
||||
"php_max_wontwork": "8.6.0",
|
||||
"current_snipeit_version": "8.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Stack
|
||||
|
||||
- **PHP 8.2+** / **Laravel 12** (framework), **Laravel Mix** (webpack) for frontend assets
|
||||
- **AdminLTE 2** / **Bootstrap 3** UI — Blade views, no Livewire/Inertia
|
||||
- **Chart.js v2.9.4** — bundled at `public/js/dist/Chart.min.js`; use `horizontalBar` type (v2 API, not v3)
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
php artisan test
|
||||
# or
|
||||
vendor/bin/phpunit
|
||||
|
||||
# Run a single test file
|
||||
php artisan test tests/Feature/Assets/AssetsTest.php
|
||||
|
||||
# Run a specific test method
|
||||
php artisan test --filter testSomeMethod
|
||||
|
||||
# Build frontend assets (dev)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run prod
|
||||
|
||||
# Laravel Mix watch
|
||||
npm run watch
|
||||
|
||||
# Tinker / REPL
|
||||
php artisan tinker
|
||||
|
||||
# Clear caches after config/route changes
|
||||
php artisan optimize:clear
|
||||
```
|
||||
|
||||
Dev server is served via **Laravel Herd** (`herd coverage` for coverage reports).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Controllers
|
||||
|
||||
Two parallel controller trees:
|
||||
- `app/Http/Controllers/` — web/UI controllers (Blade views)
|
||||
- `app/Http/Controllers/Api/` — REST API controllers (JSON, used by datatables + select2)
|
||||
|
||||
Subdirectory groupings: `Assets/`, `Licenses/`, `Users/`, `Accessories/`, `Consumables/`, `Components/`, `Kits/`, `Account/`, `Auth/`
|
||||
|
||||
### API Pattern
|
||||
|
||||
Every API controller returns data via a **Transformer** (`app/Http/Transformers/`). Never return raw model attributes from API controllers — always pass through the transformer. `DatatablesTransformer` wraps paginated results.
|
||||
|
||||
```php
|
||||
return (new AssetsTransformer)->transformAssets($assets, $assets->count());
|
||||
```
|
||||
|
||||
### Authorization
|
||||
|
||||
All authorization goes through **Policies** (`app/Policies/`). `CheckoutablePermissionsPolicy` is the base for assets/licenses/accessories/consumables — its `checkout()` / `checkin()` methods accept `$item = null` so you can use `@can('checkout', \App\Models\Asset::class)` without an instance.
|
||||
|
||||
### FMCS (Full Multiple Company Support)
|
||||
|
||||
`Setting::getSettings()->full_multiple_companies_support == '1'` gates company-scoped filtering. The select2 API endpoints (`selectlist()` methods) accept a `companyId` query param — apply it like this:
|
||||
|
||||
```php
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
|
||||
$query->where('table.company_id', $request->input('companyId'));
|
||||
}
|
||||
```
|
||||
|
||||
Pass `data-company-id="{{ $user->company_id }}"` in Blade to wire it to select2.
|
||||
|
||||
### Select2 AJAX Dropdowns
|
||||
|
||||
Use `class="js-data-ajax"` with `data-endpoint="hardware|licenses|consumables|..."`. `snipeit.js` auto-initializes these, forwarding `data-company-id` as `companyId` and `data-asset-status-type` as `statusType` to the API.
|
||||
|
||||
### Routes
|
||||
|
||||
All routes are in `routes/web.php` (UI) and `routes/api.php` (API). Breadcrumbs are defined inline using `->breadcrumbs(fn (Trail $trail) => ...)` from `tabuna/breadcrumbs`. Every UI route should have a breadcrumb.
|
||||
|
||||
Note: the `reports/unaccepted_assets` route is named with slashes, not dots — use `route('reports/unaccepted_assets')`.
|
||||
|
||||
### Translations
|
||||
|
||||
String keys live in `resources/lang/en-US/general.php` (and other files in that directory). Always add new UI strings as translation keys rather than hard-coding English.
|
||||
|
||||
### Checkout Redirect Flow
|
||||
|
||||
After checkout, `Helper::getRedirectOption()` reads `$request->redirect_option`. For redirecting back to the assigned user after checkout:
|
||||
- Set `redirect_option=target` in the form
|
||||
- Set `checkout_to_type=user` in the form
|
||||
- Set `assigned_user={{ $user->id }}` in the form
|
||||
|
||||
### Key Helper Methods (`app/Helpers/Helper.php`)
|
||||
|
||||
- `Helper::deployableStatusLabelList()` — status labels for checkout forms
|
||||
- `Helper::defaultChartColors()` — 10-color palette used in charts
|
||||
- `Helper::getRedirectOption($request, $id, $table)` — post-checkout redirect logic
|
||||
|
||||
### Global View Variables
|
||||
|
||||
`$snipeSettings` is injected into all views via a service provider — no need to pass `Setting::getSettings()` from every controller. Use it directly in Blade.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `tests/Feature/` (organized by entity) and `tests/Unit/`. Feature tests hit the database; the test environment uses `array` cache/session/mail drivers. Tests use factories for data setup.
|
||||
+1
-1
@@ -69,7 +69,7 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken
|
||||
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") |
|
||||
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") |
|
||||
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [<img src="https://avatars.githubusercontent.com/u/2880129?v=4" width="110px;"/><br /><sub>Sebastian Marsching</sub>](http://sebastian.marsching.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [<img src="https://avatars.githubusercontent.com/u/40658372?v=4" width="110px;"/><br /><sub>Mo</sub>](https://github.com/mohammad-ahmadi1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") |
|
||||
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") |
|
||||
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") |
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
+15
-4
@@ -10,9 +10,9 @@ however there are times when library dependencies and/or PHP/MySQL dependencies
|
||||
make it impossible to backport security fixes on older versions.
|
||||
|
||||
| Version | Supported |
|
||||
|---------| ------------------ |
|
||||
|---------|--------------------|
|
||||
| 8.x | :white_check_mark: |
|
||||
| 7.x | :white_check_mark: |
|
||||
| 7.x | :x: |
|
||||
| 6.x | :x: |
|
||||
| 5.1.x | :x: |
|
||||
| 5.0.x | :x: |
|
||||
@@ -24,7 +24,18 @@ make it impossible to backport security fixes on older versions.
|
||||
Security vulnerabilities should be sent to security@snipeitapp.com. You can typically expect a
|
||||
response within two business days, and we typically have fixes out in under a week from the initial disclosure.
|
||||
|
||||
This obviously varies based on the severity of the security issue and the difficulty in remediation,
|
||||
but those have historically been the timelines we worm around.
|
||||
This obviously varies based on the severity of the security issue and the difficulty in remediation, but those have
|
||||
historically been the timelines we work around.
|
||||
|
||||
We do ask that you do not disclose the vulnerability publicly until we have had a chance to address it and tag a release
|
||||
so that we can protect our users, and we will work
|
||||
with you to coordinate a public disclosure once we have a fix out. We will also work with you to ensure that you receive
|
||||
appropriate credit for the discovery of the vulnerability, if you would like to be credited. (Please provide a GitHub
|
||||
username or other information if you would like to be credited, and please let us know if you would like to remain
|
||||
anonymous.)
|
||||
|
||||
For responsible disclosure, we ask that you give us at least __90 days__ to address the issue before disclosing it
|
||||
publicly,
|
||||
but we will work with you if you need to disclose it sooner than that.
|
||||
|
||||
For a full breakdown of our security policies, please see https://snipeitapp.com/security.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Breadcrumbs;
|
||||
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Tabuna\Breadcrumbs\Trail;
|
||||
|
||||
final class BuildAcceptanceBreadcrumbs
|
||||
{
|
||||
public static function forAcceptance(Trail $trail, CheckoutAcceptance|int|string $acceptance): void
|
||||
{
|
||||
$acceptance = self::resolveAcceptance($acceptance);
|
||||
$trail->parent('home');
|
||||
|
||||
if (! $acceptance instanceof CheckoutAcceptance) {
|
||||
self::appendProfileContext($trail);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::isSignInPlaceFlow($acceptance)) {
|
||||
self::appendProfileContext($trail);
|
||||
$trail->push(trans('general.accept_item'), route('account.accept.item', $acceptance));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::appendCheckoutFlowContext($trail, $acceptance);
|
||||
$trail->push(self::buildSignInPlaceLabel($acceptance));
|
||||
}
|
||||
|
||||
private static function resolveAcceptance(CheckoutAcceptance|int|string $acceptance): ?CheckoutAcceptance
|
||||
{
|
||||
if ($acceptance instanceof CheckoutAcceptance) {
|
||||
return $acceptance;
|
||||
}
|
||||
|
||||
if (is_numeric($acceptance)) {
|
||||
return CheckoutAcceptance::find((int) $acceptance);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function isSignInPlaceFlow(CheckoutAcceptance $acceptance): bool
|
||||
{
|
||||
return (int) session('sign_in_place_acceptance_id') === (int) $acceptance->id;
|
||||
}
|
||||
|
||||
private static function appendProfileContext(Trail $trail): void
|
||||
{
|
||||
$trail->push(trans('general.profile'), route('account'));
|
||||
$trail->push(trans('general.accept_items'), route('account.accept'));
|
||||
}
|
||||
|
||||
private static function appendCheckoutFlowContext(Trail $trail, CheckoutAcceptance $acceptance): void
|
||||
{
|
||||
$checkoutable = $acceptance->checkoutable;
|
||||
|
||||
if ($checkoutable instanceof Asset) {
|
||||
$trail->push(trans('general.assets'), route('hardware.index'));
|
||||
$trail->push($checkoutable->display_name ?? trans('general.asset'), route('hardware.show', $checkoutable));
|
||||
$trail->push(trans('general.checkout'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
$license = $checkoutable->license;
|
||||
|
||||
if ($license instanceof License) {
|
||||
$trail->push(trans('general.licenses'), route('licenses.index'));
|
||||
$trail->push($license->display_name ?? trans('general.license'), route('licenses.show', $license));
|
||||
$trail->push(trans('general.checkout'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Consumable) {
|
||||
$trail->push(trans('general.consumables'), route('consumables.index'));
|
||||
$trail->push($checkoutable->display_name ?? trans('general.consumable'), route('consumables.show', $checkoutable));
|
||||
$trail->push(trans('general.checkout'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Accessory) {
|
||||
$trail->push(trans('general.accessories'), route('accessories.index'));
|
||||
$trail->push($checkoutable->display_name ?? trans('general.accessory'), route('accessories.show', $checkoutable));
|
||||
$trail->push(trans('general.checkout'));
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildSignInPlaceLabel(CheckoutAcceptance $acceptance): string
|
||||
{
|
||||
if ($acceptance->assignedTo instanceof User) {
|
||||
return sprintf('%s for %s', trans('general.sign_in_place'), $acceptance->assignedTo->display_name);
|
||||
}
|
||||
|
||||
return trans('general.sign_in_place');
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class LdapSync extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--summary} {--json_summary}';
|
||||
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--delete} {--summary} {--json_summary}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -94,6 +94,7 @@ class LdapSync extends Command
|
||||
}
|
||||
|
||||
$summary = [];
|
||||
$seen_ldap_usernames = [];
|
||||
|
||||
try {
|
||||
|
||||
@@ -274,8 +275,14 @@ class LdapSync extends Command
|
||||
'name' => $item['department'],
|
||||
]);
|
||||
|
||||
$user = User::where('username', $item['username'])->first();
|
||||
$user = User::withTrashed()->where('username', $item['username'])->first();
|
||||
if (! empty($item['username'])) {
|
||||
$seen_ldap_usernames[] = $item['username'];
|
||||
}
|
||||
if ($user) {
|
||||
if ($user->trashed()) {
|
||||
$user->restore();
|
||||
}
|
||||
// Updating an existing user.
|
||||
$item['createorupdate'] = 'updated';
|
||||
} else {
|
||||
@@ -490,6 +497,41 @@ class LdapSync extends Command
|
||||
array_push($summary, $item);
|
||||
}
|
||||
|
||||
// Optionally soft-delete LDAP-imported users that are no longer present in LDAP.
|
||||
// users with assests etc. are not deletable and skipped
|
||||
if ($this->option('delete')) {
|
||||
$missing_ldap_users = User::where('ldap_import', 1);
|
||||
$missing_ldap_users = $missing_ldap_users->whereNotIn('username', $seen_ldap_usernames);
|
||||
$missing_ldap_users = $missing_ldap_users->get();
|
||||
|
||||
foreach ($missing_ldap_users as $missing_user) {
|
||||
$is_deletable = $this->isUserDeletable($missing_user);
|
||||
|
||||
$missing_item = [
|
||||
'id' => $missing_user->id,
|
||||
'username' => $missing_user->username,
|
||||
'firstname' => $missing_user->first_name,
|
||||
'lastname' => $missing_user->last_name,
|
||||
'email' => $missing_user->email,
|
||||
'createorupdate' => 'skipped',
|
||||
'status' => 'info',
|
||||
'deletable' => $is_deletable,
|
||||
'note' => $is_deletable ? 'missing from LDAP' : 'missing from LDAP, but not deletable',
|
||||
];
|
||||
|
||||
if ($is_deletable) {
|
||||
$missing_user->delete();
|
||||
$missing_item['createorupdate'] = 'deleted';
|
||||
$missing_item['status'] = 'success';
|
||||
$missing_item['note'] = 'deleted_missing_from_ldap';
|
||||
}
|
||||
|
||||
$summary[] = $missing_item;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if ($this->option('summary')) {
|
||||
for ($x = 0; $x < count($summary); $x++) {
|
||||
if ($summary[$x]['status'] == 'error') {
|
||||
@@ -505,4 +547,23 @@ class LdapSync extends Command
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is deletable without gate check
|
||||
*
|
||||
* A user is considered deletable if they have no associated assets, accessories, licenses, consumables, managed users, or managed locations.
|
||||
*
|
||||
* @param User $user The user to check
|
||||
*
|
||||
* @return bool True if the user is deletable, false otherwise
|
||||
*/
|
||||
private function isUserDeletable(User $user): bool
|
||||
{
|
||||
return (($user->assets_count ?? $user->assets()->count()) === 0)
|
||||
&& (($user->accessories_count ?? $user->accessories()->count()) === 0)
|
||||
&& (($user->licenses_count ?? $user->licenses()->count()) === 0)
|
||||
&& (($user->consumables_count ?? $user->consumables()->count()) === 0)
|
||||
&& (($user->manages_users_count ?? $user->managesUsers()->count()) === 0)
|
||||
&& (($user->manages_locations_count ?? $user->managedLocations()->count()) === 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ class PurgeEulaPDFs extends Command
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'snipeit:purge-eula-pdfs
|
||||
{--older-than-days= : The number of days we should delete before }
|
||||
{--older-than-days= : The number of days we should delete before }
|
||||
{--company-id= : Only purge acceptances for users in this company}
|
||||
{--only-deleted-users : Only purge acceptances for deleted users, including soft-deleted or missing users}
|
||||
{--force : Skip the interactive yes/no prompt for confirmation}
|
||||
{--dryrun : Show the records that would be deleted but don\'t update the database or delete files from disk}
|
||||
{--with-output : Display the results in a table in your console}';
|
||||
@@ -55,7 +57,34 @@ class PurgeEulaPDFs extends Command
|
||||
$this->info('This script is being run with the --dryrun option. No files or records will be deleted.');
|
||||
|
||||
}
|
||||
$acceptances = CheckoutAcceptance::HasFiles()->where('updated_at', '<', $interval_date)->with('assignedTo')->get();
|
||||
$companyId = $this->option('company-id');
|
||||
$query = CheckoutAcceptance::HasFiles()->where('updated_at', '<', $interval_date)
|
||||
->with([
|
||||
'assignedTo' => function ($query) {
|
||||
$query->withTrashed();
|
||||
},
|
||||
]);
|
||||
|
||||
if ($this->option('only-deleted-users')) {
|
||||
$query->where(function ($query) use ($companyId) {
|
||||
$query->whereHas('assignedTo', function ($q) use ($companyId) {
|
||||
$q->withTrashed()->whereNotNull('deleted_at');
|
||||
|
||||
if ($companyId) {
|
||||
$q->where('company_id', $companyId);
|
||||
}
|
||||
});
|
||||
|
||||
$query->orWhereDoesntHave('assignedTo');
|
||||
});
|
||||
} else {
|
||||
if ($companyId) {
|
||||
$query->whereHas('assignedTo', function ($query) use ($companyId) {
|
||||
$query->withTrashed()->where('company_id', $companyId);
|
||||
});
|
||||
}
|
||||
}
|
||||
$acceptances = $query->get();
|
||||
|
||||
if (! $this->option('force')) {
|
||||
if ($this->confirm("\n****************************************************\nTHIS WILL DELETE ALL OF THE SIGNATURES AND EULA PDF FILES SINCE $interval_date. \nThere is NO undo! \n****************************************************\n\nDo you wish to continue? No backsies! [y|N]")) {
|
||||
|
||||
@@ -456,7 +456,11 @@ class RestoreFromBackup extends Command
|
||||
if (! file_exists($mysql_binary)) {
|
||||
return $this->error("mysql tool at: '$mysql_binary' does not exist, cannot restore. Please edit DB_DUMP_PATH in your .env to point to a directory that contains the mysqldump and mysql binary");
|
||||
}
|
||||
$proc_results = proc_open("$mysql_binary -h ".escapeshellarg(config('database.connections.mysql.host')).' -u '.escapeshellarg(config('database.connections.mysql.username')).' '.escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
|
||||
$proc_results = proc_open("$mysql_binary -h " .
|
||||
escapeshellarg(config('database.connections.mysql.host')) .
|
||||
' -u ' . escapeshellarg(config('database.connections.mysql.username')) . ' ' .
|
||||
' -P ' . escapeshellarg(config('database.connections.mysql.port')) . ' ' .
|
||||
escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes,
|
||||
null,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Asset;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\MessageBag;
|
||||
|
||||
class ValidateAssets extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'snipeit:validate-assets {--all : Display the valid assets in your table output as well} ';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'This runs through the list of assets and checks for any validation errors that would prevent it from being updated or checked in or out. ';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$showAll = (bool) $this->option('all');
|
||||
|
||||
$assets = Asset::query()
|
||||
->whereNull('deleted_at')
|
||||
->with('model')
|
||||
->orderBy('assets.created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if (! $showAll) {
|
||||
$this->info('Run this command with the --all option to see the full list in the console.');
|
||||
}
|
||||
|
||||
$rows = $assets
|
||||
->filter(fn (Asset $asset) => $showAll || ! $asset->isValid())
|
||||
->map(fn (Asset $asset) => [
|
||||
trans('general.id') => $asset->id,
|
||||
trans('admin/hardware/form.tag') => $asset->asset_tag,
|
||||
trans('admin/hardware/form.serial') => $asset->serial ?? '',
|
||||
trans('admin/hardware/form.model') => $asset->model?->name ?? '',
|
||||
trans('general.model_no') => $asset->model?->model_number ?? '',
|
||||
trans('general.error') => $asset->isValid() ? '√ valid' : $this->formatValidationErrors($asset),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->table(
|
||||
[
|
||||
trans('general.id'),
|
||||
trans('admin/hardware/form.tag'),
|
||||
trans('admin/hardware/form.serial'),
|
||||
trans('admin/hardware/form.model'),
|
||||
trans('general.model_no'),
|
||||
trans('general.error'),
|
||||
],
|
||||
$rows
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatValidationErrors(Asset $asset): string
|
||||
{
|
||||
$errors = $asset->getErrors();
|
||||
$messages = [];
|
||||
|
||||
if ($errors instanceof MessageBag) {
|
||||
$messages = $errors->all();
|
||||
} elseif (is_array($errors)) {
|
||||
$messages = $errors;
|
||||
} else {
|
||||
$messages = [(string) $errors];
|
||||
}
|
||||
|
||||
$prefixedMessages = collect($messages)
|
||||
->map(fn ($message) => trim((string) $message))
|
||||
->filter()
|
||||
->map(fn (string $message) => str_starts_with($message, '✘') ? $message : '✘ '.$message)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return implode(PHP_EOL, $prefixedMessages);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ enum ActionType: string
|
||||
// Assets/Accessories/Components/Licenses/Consumables
|
||||
case Checkout = 'checkout';
|
||||
case CheckinFrom = 'checkin from';
|
||||
case ForceCheckin = 'force checkin';
|
||||
case Requested = 'requested';
|
||||
case RequestCanceled = 'request canceled';
|
||||
case Accepted = 'accepted';
|
||||
@@ -23,6 +24,8 @@ enum ActionType: string
|
||||
// Users
|
||||
case TwoFactorReset = '2FA reset';
|
||||
case Merged = 'merged';
|
||||
case TokenRevoked = 'token revoked';
|
||||
case TokenUnrevoked = 'token unrevoked';
|
||||
|
||||
// Licenses
|
||||
case DeleteSeats = 'delete seats';
|
||||
|
||||
@@ -22,12 +22,14 @@ class CheckoutableCheckedOut
|
||||
|
||||
public int $quantity;
|
||||
|
||||
public bool $signInPlace;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1)
|
||||
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1, bool $signInPlace = false)
|
||||
{
|
||||
$this->checkoutable = $checkoutable;
|
||||
$this->checkedOutTo = $checkedOutTo;
|
||||
@@ -35,5 +37,6 @@ class CheckoutableCheckedOut
|
||||
$this->note = $note;
|
||||
$this->originalValues = $originalValues;
|
||||
$this->quantity = $quantity;
|
||||
$this->signInPlace = $signInPlace;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ class Handler extends ExceptionHandler
|
||||
protected function unauthenticated($request, AuthenticationException $exception)
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['error' => 'Unauthorized or unauthenticated.'], 401);
|
||||
return response()->json(['error' => trans('general.unauthorized')], 401);
|
||||
}
|
||||
|
||||
return redirect()->guest('login');
|
||||
|
||||
+13
-3
@@ -1629,10 +1629,20 @@ class Helper
|
||||
|
||||
// return to assignment target
|
||||
if ($redirect_option == 'target') {
|
||||
$userId = $request->assigned_user ?? $checkedInFrom;
|
||||
$locationId = $request->assigned_location ?? $checkedInFrom;
|
||||
$assetId = $request->assigned_asset ?? $checkedInFrom;
|
||||
|
||||
return match ($checkout_to_type) {
|
||||
'user' => redirect()->route('users.show', $request->assigned_user ?? $checkedInFrom),
|
||||
'location' => redirect()->route('locations.show', $request->assigned_location ?? $checkedInFrom),
|
||||
'asset' => redirect()->route('hardware.show', $request->assigned_asset ?? $checkedInFrom),
|
||||
'user' => $userId
|
||||
? redirect()->route('users.show', $userId)
|
||||
: redirect()->route('users.index'),
|
||||
'location' => $locationId
|
||||
? redirect()->route('locations.show', $locationId)
|
||||
: redirect()->route('locations.index'),
|
||||
'asset' => $assetId
|
||||
? redirect()->route('hardware.show', $assetId)
|
||||
: redirect()->route('hardware.index'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AccessoryCheckoutRequest;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -88,12 +89,53 @@ class AccessoryCheckoutController extends Controller
|
||||
$request->input('note'),
|
||||
[],
|
||||
$accessory->checkout_qty,
|
||||
$request->boolean('sign_in_place'),
|
||||
));
|
||||
|
||||
$request->request->add(['checkout_to_type' => request('checkout_to_type')]);
|
||||
$request->request->add(['assigned_to' => $target->id]);
|
||||
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => $request->input('checkout_to_type'),
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
|
||||
// When sign_in_place is requested for a user checkout, redirect to the
|
||||
// acceptance/signature page so the user can sign in person.
|
||||
if ($request->boolean('sign_in_place') && ! in_array($request->input('checkout_to_type'), ['asset', 'location'], true)) {
|
||||
$targetUser = User::find($target->id);
|
||||
|
||||
if (! $targetUser instanceof User) {
|
||||
return redirect()->route('accessories.checkout.show', $accessory)
|
||||
->with('error', trans('admin/accessories/message.checkout.user_does_not_exist'));
|
||||
}
|
||||
|
||||
$acceptance = CheckoutAcceptance::where('checkoutable_type', Accessory::class)
|
||||
->where('checkoutable_id', $accessory->id)
|
||||
->where('assigned_to_id', $targetUser->id)
|
||||
->pending()
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// If requireAcceptance() is false the listener won't have created one; create it now.
|
||||
if (! $acceptance) {
|
||||
$acceptance = new CheckoutAcceptance;
|
||||
$acceptance->checkoutable()->associate($accessory);
|
||||
$acceptance->assignedTo()->associate($targetUser);
|
||||
$acceptance->qty = $accessory->checkout_qty;
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
session([
|
||||
'sign_in_place_acceptance_id' => $acceptance->id,
|
||||
'sign_in_place_item_id' => $accessory->id,
|
||||
'sign_in_place_resource_type' => 'Accessories',
|
||||
]);
|
||||
|
||||
return redirect()->route('account.accept.item', $acceptance->id)
|
||||
->with('success', trans('admin/accessories/message.checkout.success'));
|
||||
}
|
||||
|
||||
// Redirect to the new accessory page
|
||||
return Helper::getRedirectOption($request, $accessory->id, 'Accessories')
|
||||
|
||||
@@ -7,8 +7,14 @@ use App\Events\CheckoutDeclined;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\CheckoutAcceptanceResponseMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\AcceptanceItemAcceptedNotification;
|
||||
@@ -40,19 +46,32 @@ class AcceptanceController extends Controller
|
||||
*
|
||||
* @param int $id
|
||||
*/
|
||||
public function create($id): View|RedirectResponse
|
||||
public function create(Request $request, $id): View|RedirectResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
if (! $currentUser instanceof User) {
|
||||
abort(403, trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$acceptance = CheckoutAcceptance::find($id);
|
||||
|
||||
if (is_null($acceptance)) {
|
||||
if (! $acceptance) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
}
|
||||
|
||||
if (! $acceptance->isPending()) {
|
||||
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
|
||||
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
|
||||
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
if (! $acceptance->isCheckedOutTo(auth()->user())) {
|
||||
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
|
||||
|
||||
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
|
||||
}
|
||||
|
||||
@@ -60,7 +79,10 @@ class AcceptanceController extends Controller
|
||||
return redirect()->route('account.accept')->with('error', trans('general.error_user_company'));
|
||||
}
|
||||
|
||||
return view('account/accept.create', compact('acceptance'));
|
||||
$checkedOutAt = Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false);
|
||||
$checkedOutBy = $this->resolveCheckoutActorName($acceptance);
|
||||
|
||||
return view('account/accept.create', compact('acceptance', 'isSignInPlaceAdminFlow', 'checkedOutAt', 'checkedOutBy'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,20 +92,36 @@ class AcceptanceController extends Controller
|
||||
*/
|
||||
public function store(Request $request, $id): RedirectResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
if (! $acceptance = CheckoutAcceptance::find($id)) {
|
||||
if (! $currentUser instanceof User) {
|
||||
abort(403, trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$acceptance = CheckoutAcceptance::find($id);
|
||||
|
||||
if (! $acceptance) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
}
|
||||
|
||||
$assigned_user = User::find($acceptance->assigned_to_id);
|
||||
$assignedUser = User::find($acceptance->assigned_to_id);
|
||||
$settings = Setting::getSettings();
|
||||
$requiresSignature = (string) $settings->require_accept_signature === '1';
|
||||
$sig_filename = '';
|
||||
$encodedSignatureImage = null;
|
||||
|
||||
if (! $acceptance->isPending()) {
|
||||
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
|
||||
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
|
||||
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
if (! $acceptance->isCheckedOutTo(auth()->user())) {
|
||||
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
|
||||
|
||||
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
|
||||
}
|
||||
|
||||
@@ -112,14 +150,25 @@ class AcceptanceController extends Controller
|
||||
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
|
||||
|
||||
// If signatures are required, make sure we have one
|
||||
if (Setting::getSettings()->require_accept_signature == '1') {
|
||||
if ($requiresSignature) {
|
||||
|
||||
// The item was accepted, check for a signature
|
||||
if ($request->filled('signature_output')) {
|
||||
$sig_filename = 'siglog-'.Str::uuid().'-'.date('Y-m-d-his').'.png';
|
||||
$data_uri = $request->input('signature_output');
|
||||
$encoded_image = explode(',', $data_uri);
|
||||
$decoded_image = base64_decode($encoded_image[1]);
|
||||
$dataUri = (string) $request->input('signature_output');
|
||||
$encodedSignatureImage = Str::contains($dataUri, ',')
|
||||
? Str::after($dataUri, ',')
|
||||
: $dataUri;
|
||||
|
||||
$decoded_image = base64_decode($encodedSignatureImage, true);
|
||||
|
||||
if ($decoded_image === false) {
|
||||
return redirect()->back()->with('error', trans('general.shitty_browser'));
|
||||
}
|
||||
|
||||
$decoded_image = $this->flattenSignatureBackgroundToWhite($decoded_image);
|
||||
$encodedSignatureImage = base64_encode($decoded_image);
|
||||
|
||||
Storage::put('private_uploads/signatures/'.$sig_filename, (string) $decoded_image);
|
||||
|
||||
// No image data is present, kick them back.
|
||||
@@ -133,7 +182,7 @@ class AcceptanceController extends Controller
|
||||
// This is needed for TCPDF to properly embed the image if it's a png and the cache isn't writable
|
||||
$encoded_logo = null;
|
||||
if (($settings->acceptance_pdf_logo) && (Storage::disk('public')->exists($settings->acceptance_pdf_logo))) {
|
||||
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.$settings->acceptance_pdf_logo));
|
||||
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.basename($settings->acceptance_pdf_logo)));
|
||||
}
|
||||
|
||||
// Get the data array ready for the notifications and PDF generation
|
||||
@@ -148,18 +197,42 @@ class AcceptanceController extends Controller
|
||||
'check_out_date' => Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false),
|
||||
'accepted_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
|
||||
'declined_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
|
||||
'assigned_to' => $assigned_user->display_name,
|
||||
'email' => $assigned_user->email,
|
||||
'employee_num' => $assigned_user->employee_num,
|
||||
'assigned_to' => $assignedUser->display_name,
|
||||
'email' => $assignedUser->email,
|
||||
'employee_num' => $assignedUser->employee_num,
|
||||
'site_name' => $settings->site_name,
|
||||
'company_name' => $item->company?->name ?? $settings->site_name,
|
||||
'signature' => (($sig_filename && array_key_exists('1', $encoded_image))) ? $encoded_image[1] : null,
|
||||
'signature' => ($sig_filename !== '') ? $encodedSignatureImage : null,
|
||||
'logo' => ($encoded_logo) ?? null,
|
||||
'date_settings' => $settings->date_display_format,
|
||||
'qty' => $acceptance->qty ?? 1,
|
||||
];
|
||||
|
||||
if ($request->input('asset_acceptance') == 'accepted') {
|
||||
// Include asset custom fields that are explicitly allowed in outbound emails/PDFs.
|
||||
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
|
||||
$customFields = [];
|
||||
$fields = $item->model->fieldset->fields
|
||||
->where('show_in_email', true)
|
||||
->where('field_encrypted', false);
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$dbColumn = $field->db_column;
|
||||
$value = $item->{$dbColumn};
|
||||
|
||||
if (! is_null($value) && $value !== '') {
|
||||
$customFields[] = [
|
||||
'label' => $field->name,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($customFields)) {
|
||||
$data['custom_fields'] = $customFields;
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->input('asset_acceptance') === 'accepted') {
|
||||
|
||||
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
|
||||
|
||||
@@ -171,12 +244,12 @@ class AcceptanceController extends Controller
|
||||
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
|
||||
|
||||
// Send the PDF to the signing user
|
||||
if (($request->input('send_copy') == '1') && ($assigned_user->email != '')) {
|
||||
if (($request->input('send_copy') === '1') && ($assignedUser->email !== '')) {
|
||||
|
||||
// Add the attachment for the signing user into the $data array
|
||||
$data['file'] = $pdf_filename;
|
||||
try {
|
||||
$assigned_user->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assigned_user->locale));
|
||||
$assignedUser->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assignedUser->locale));
|
||||
} catch (Exception $e) {
|
||||
Log::warning($e);
|
||||
}
|
||||
@@ -215,7 +288,7 @@ class AcceptanceController extends Controller
|
||||
$recipient,
|
||||
$request->input('asset_acceptance') === 'accepted',
|
||||
));
|
||||
Log::debug('Send email notification sucess on checkout acceptance response.');
|
||||
Log::debug('Send email notification success on checkout acceptance response.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
@@ -223,7 +296,163 @@ class AcceptanceController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($isSignInPlaceAdminFlow) {
|
||||
$request->request->add(['assigned_user' => $assignedUser?->id]);
|
||||
|
||||
$redirect = Helper::getRedirectOption(
|
||||
$request,
|
||||
session('sign_in_place_item_id'),
|
||||
session('sign_in_place_resource_type'),
|
||||
);
|
||||
|
||||
session()->forget([
|
||||
'sign_in_place_acceptance_id',
|
||||
'sign_in_place_item_id',
|
||||
'sign_in_place_resource_type',
|
||||
]);
|
||||
|
||||
return $redirect->with('success', $return_msg);
|
||||
}
|
||||
|
||||
return redirect()->to('account/accept')->with('success', $return_msg);
|
||||
|
||||
}
|
||||
|
||||
private function isSignInPlaceAdminFlow(CheckoutAcceptance $acceptance): bool
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
return ((int) session('sign_in_place_acceptance_id') === (int) $acceptance->id)
|
||||
&& ($currentUser?->can('checkout', $acceptance->checkoutable));
|
||||
}
|
||||
|
||||
private function resolveCheckoutActorName(CheckoutAcceptance $acceptance): ?string
|
||||
{
|
||||
[$itemType, $itemId] = $this->resolveCheckoutLogItem($acceptance);
|
||||
|
||||
$checkoutLog = Actionlog::query()
|
||||
->where('action_type', 'checkout')
|
||||
->where('item_type', $itemType)
|
||||
->where('item_id', $itemId)
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $acceptance->assigned_to_id)
|
||||
->where('created_at', '<=', $acceptance->created_at->copy()->addMinutes(5))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
return $checkoutLog?->adminuser?->display_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action logs normalize license seat checkouts to the parent license.
|
||||
*
|
||||
* @return array{0: class-string, 1: int}
|
||||
*/
|
||||
private function resolveCheckoutLogItem(CheckoutAcceptance $acceptance): array
|
||||
{
|
||||
$checkoutable = $acceptance->checkoutable;
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
return [License::class, (int) $checkoutable->license_id];
|
||||
}
|
||||
|
||||
return [$acceptance->checkoutable_type, (int) $acceptance->checkoutable_id];
|
||||
}
|
||||
|
||||
private function isStaleSignInPlaceAdminAttempt(CheckoutAcceptance $acceptance, User $currentUser): bool
|
||||
{
|
||||
$redirectOption = session('redirect_option');
|
||||
$checkoutToType = session('checkout_to_type');
|
||||
|
||||
if (session('sign_in_place') !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($redirectOption === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($redirectOption === 'target' && $checkoutToType === 'user' && empty($acceptance->assigned_to_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $acceptance->isCheckedOutTo($currentUser)
|
||||
&& $currentUser->can('checkout', $acceptance->checkoutable)
|
||||
&& ($checkoutToType === 'user');
|
||||
}
|
||||
|
||||
private function redirectToIntendedSignInPlaceDestination(Request $request, CheckoutAcceptance $acceptance): RedirectResponse
|
||||
{
|
||||
if (empty($acceptance->assigned_to_id)) {
|
||||
return redirect()->route('account.accept');
|
||||
}
|
||||
|
||||
[$itemId, $resourceType] = $this->resolveRedirectTarget($acceptance);
|
||||
|
||||
$request->request->add(['assigned_user' => $acceptance->assigned_to_id]);
|
||||
|
||||
return Helper::getRedirectOption($request, $itemId, $resourceType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: string}
|
||||
*/
|
||||
private function resolveRedirectTarget(CheckoutAcceptance $acceptance): array
|
||||
{
|
||||
$checkoutable = $acceptance->checkoutable;
|
||||
|
||||
if ($checkoutable instanceof Asset) {
|
||||
return [(int) $checkoutable->id, 'Assets'];
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Accessory) {
|
||||
return [(int) $checkoutable->id, 'Accessories'];
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Consumable) {
|
||||
return [(int) $checkoutable->id, 'Consumables'];
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
return [(int) $checkoutable->license_id, 'Licenses'];
|
||||
}
|
||||
|
||||
return [(int) $acceptance->checkoutable_id, session('sign_in_place_resource_type', 'Assets')];
|
||||
}
|
||||
|
||||
private function flattenSignatureBackgroundToWhite(string $signatureBinary): string
|
||||
{
|
||||
if (! function_exists('imagecreatefromstring') || ! function_exists('imagecreatetruecolor')) {
|
||||
return $signatureBinary;
|
||||
}
|
||||
|
||||
$source = @imagecreatefromstring($signatureBinary);
|
||||
|
||||
if ($source === false) {
|
||||
return $signatureBinary;
|
||||
}
|
||||
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
$flattened = imagecreatetruecolor($width, $height);
|
||||
|
||||
if ($flattened === false) {
|
||||
imagedestroy($source);
|
||||
|
||||
return $signatureBinary;
|
||||
}
|
||||
|
||||
$white = imagecolorallocate($flattened, 255, 255, 255);
|
||||
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
|
||||
imagecopy($flattened, $source, 0, 0, 0, 0, $width, $height);
|
||||
|
||||
ob_start();
|
||||
imagepng($flattened);
|
||||
$output = ob_get_clean();
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($flattened);
|
||||
|
||||
return is_string($output) ? $output : $signatureBinary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ class ActionlogController extends Controller
|
||||
{
|
||||
public function displaySig($filename): RedirectResponse|Response|bool
|
||||
{
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
// PHP doesn't let you handle file not found errors well with
|
||||
// file_get_contents, so we set the error reporting for just this class
|
||||
error_reporting(0);
|
||||
@@ -44,6 +46,7 @@ class ActionlogController extends Controller
|
||||
|
||||
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
|
||||
{
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
if ($actionlog = Actionlog::where('filename', $filename)->with('user')->with('target')->firstOrFail()) {
|
||||
|
||||
|
||||
@@ -14,11 +14,14 @@ 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
|
||||
{
|
||||
@@ -84,23 +87,23 @@ class AccessoriesController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$accessories->where('category_id', '=', $request->input('category_id'));
|
||||
$accessories->where('accessories.category_id', '=', $request->input('category_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('manufacturer_id')) {
|
||||
$accessories->where('manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
$accessories->where('accessories.manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('supplier_id')) {
|
||||
$accessories->where('supplier_id', '=', $request->input('supplier_id'));
|
||||
$accessories->where('accessories.supplier_id', '=', $request->input('supplier_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
$accessories->where('location_id', '=', $request->input('location_id'));
|
||||
$accessories->where('accessories.location_id', '=', $request->input('location_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('notes')) {
|
||||
$accessories->where('notes', '=', $request->input('notes'));
|
||||
$accessories->where('accessories.notes', '=', $request->input('notes'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
@@ -155,6 +158,7 @@ class AccessoriesController extends Controller
|
||||
{
|
||||
$accessory = new Accessory;
|
||||
$accessory->fill($request->all());
|
||||
$accessory->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$accessory = $request->handleImages($accessory);
|
||||
|
||||
if ($accessory->save()) {
|
||||
@@ -248,6 +252,7 @@ class AccessoriesController extends Controller
|
||||
$this->authorize('update', Accessory::class);
|
||||
$accessory = Accessory::findOrFail($id);
|
||||
$accessory->fill($request->all());
|
||||
$accessory->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$accessory = $request->handleImages($accessory);
|
||||
|
||||
if ($accessory->save()) {
|
||||
@@ -297,40 +302,49 @@ class AccessoriesController extends Controller
|
||||
{
|
||||
$this->authorize('checkout', $accessory);
|
||||
$target = $this->determineCheckoutTarget();
|
||||
$accessory->checkout_qty = $request->input('checkout_qty', 1);
|
||||
|
||||
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
|
||||
|
||||
$accessory_checkout = new AccessoryCheckout([
|
||||
'accessory_id' => $accessory->id,
|
||||
'created_at' => Carbon::now(),
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => $target::class,
|
||||
'note' => $request->input('note'),
|
||||
]);
|
||||
|
||||
$accessory_checkout->created_by = auth()->id();
|
||||
$accessory_checkout->save();
|
||||
|
||||
$payload = [
|
||||
'accessory_id' => $accessory->id,
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => $target::class,
|
||||
'note' => $request->input('note'),
|
||||
'created_by' => auth()->id(),
|
||||
'pivot' => $accessory_checkout->id,
|
||||
];
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($accessory->company_id !== $target->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
// Set this value to be able to pass the qty through to the event
|
||||
event(new CheckoutableCheckedOut(
|
||||
$accessory,
|
||||
$target,
|
||||
auth()->user(),
|
||||
$request->input('note'),
|
||||
[],
|
||||
$accessory->checkout_qty,
|
||||
));
|
||||
$accessory->checkout_qty = $request->input('checkout_qty', 1);
|
||||
$payload = null;
|
||||
|
||||
// Keep checkout rows and checkout log/event atomic to avoid ghost assignments.
|
||||
DB::transaction(function () use ($accessory, $request, $target, &$payload): void {
|
||||
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
|
||||
|
||||
$accessory_checkout = new AccessoryCheckout([
|
||||
'accessory_id' => $accessory->id,
|
||||
'created_at' => Carbon::now(),
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => $target::class,
|
||||
'note' => $request->input('note'),
|
||||
]);
|
||||
|
||||
$accessory_checkout->created_by = auth()->id();
|
||||
$accessory_checkout->save();
|
||||
|
||||
$payload = [
|
||||
'accessory_id' => $accessory->id,
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => $target::class,
|
||||
'note' => $request->input('note'),
|
||||
'created_by' => auth()->id(),
|
||||
'pivot' => $accessory_checkout->id,
|
||||
];
|
||||
}
|
||||
|
||||
// Set this value to be able to pass the qty through to the event.
|
||||
event(new CheckoutableCheckedOut(
|
||||
$accessory,
|
||||
$target,
|
||||
auth()->user(),
|
||||
$request->input('note'),
|
||||
[],
|
||||
$accessory->checkout_qty,
|
||||
));
|
||||
});
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));
|
||||
|
||||
@@ -405,11 +419,11 @@ class AccessoriesController extends Controller
|
||||
public function history(Request $request, Accessory $accessory): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $accessory);
|
||||
$history = $accessory->getHistory($request);
|
||||
$total = $accessory->getHistory($request)->count();
|
||||
$historyQuery = $accessory->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = $history->skip($offset)->take($limit)->get();
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -343,11 +343,11 @@ class AssetModelsController extends Controller
|
||||
public function history(Request $request, AssetModel $model): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $model);
|
||||
$history = $model->getHistory($request);
|
||||
$total = $model->getHistory($request)->count();
|
||||
$historyQuery = $model->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = $history->skip($offset)->take($limit)->get();
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* This class controls all actions related to assets for
|
||||
@@ -219,10 +220,18 @@ class AssetsController extends Controller
|
||||
|
||||
// This is used by the sidenav, mostly
|
||||
|
||||
// We switched from using query scopes here because of a Laravel bug
|
||||
// related to fulltext searches on complex queries.
|
||||
// I am sad. :(
|
||||
switch ($request->input('status_type')) {
|
||||
// This bit here accounts for folks actually using the formerly-known-as status like we previously used in the sidenav
|
||||
// to return a list of all assets with the status *type* of Deployed, etc. The inuput field used to be "status" (which was consistent
|
||||
// with the relation rename, but it broke the sidebar. This should handle both use cases in the event that someone didn't update
|
||||
// their API integration code
|
||||
$status_type_key = null;
|
||||
if ($request->filled('status_type')) {
|
||||
$status_type_key = $request->input('status_type');
|
||||
} elseif ($request->filled('status')) {
|
||||
$status_type_key = $request->input('status');
|
||||
}
|
||||
|
||||
switch ($status_type_key) {
|
||||
case 'Deleted':
|
||||
$assets->onlyTrashed();
|
||||
break;
|
||||
@@ -697,18 +706,35 @@ class AssetsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($asset->save()) {
|
||||
if ($request->input('assigned_user')) {
|
||||
$target = User::find(request('assigned_user'));
|
||||
} elseif ($request->input('assigned_asset')) {
|
||||
$target = Asset::find(request('assigned_asset'));
|
||||
} elseif ($request->input('assigned_location')) {
|
||||
$target = Location::find(request('assigned_location'));
|
||||
$target = $this->resolveCheckoutTargetForAssetMutation($request);
|
||||
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
|
||||
|
||||
if ($requestedCheckout && (! $target)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
|
||||
}
|
||||
|
||||
if ($requestedCheckout) {
|
||||
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
|
||||
if ($companyMismatchResponse) {
|
||||
return $companyMismatchResponse;
|
||||
}
|
||||
if (isset($target)) {
|
||||
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
|
||||
}
|
||||
|
||||
$stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
|
||||
if (! $asset->save()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($requestedCheckout) {
|
||||
// Keep create + optional checkout side effects atomic.
|
||||
return $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if ($stored) {
|
||||
|
||||
if ($asset->image) {
|
||||
$asset->image = $asset->getImageUrl();
|
||||
}
|
||||
@@ -783,25 +809,54 @@ class AssetsController extends Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($asset->save()) {
|
||||
if (($request->filled('assigned_user')) && ($target = User::find($request->input('assigned_user')))) {
|
||||
$location = $target->location_id;
|
||||
} elseif (($request->filled('assigned_asset')) && ($target = Asset::find($request->input('assigned_asset')))) {
|
||||
$location = $target->location_id;
|
||||
$target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
|
||||
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
|
||||
|
||||
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
|
||||
->update(['location_id' => $target->location_id]);
|
||||
} elseif (($request->filled('assigned_location')) && ($target = Location::find($request->input('assigned_location')))) {
|
||||
$location = $target->id;
|
||||
if ($requestedCheckout && (! $target)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
|
||||
}
|
||||
|
||||
if ($requestedCheckout) {
|
||||
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
|
||||
if ($companyMismatchResponse) {
|
||||
return $companyMismatchResponse;
|
||||
}
|
||||
}
|
||||
|
||||
$updated = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
|
||||
if (! $asset->save()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($target)) {
|
||||
if ($requestedCheckout) {
|
||||
// Using `->has` preserves the asset name if the name parameter was not included in request.
|
||||
$asset_name = request()->has('name') ? request('name') : $asset->name;
|
||||
|
||||
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location);
|
||||
$location = null;
|
||||
if ($request->filled('assigned_user')) {
|
||||
$location = $target->location_id;
|
||||
} elseif ($request->filled('assigned_asset')) {
|
||||
$location = $target->location_id;
|
||||
} elseif ($request->filled('assigned_location')) {
|
||||
$location = $target->id;
|
||||
}
|
||||
|
||||
// Keep update + optional checkout side effects atomic.
|
||||
if (! $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($request->filled('assigned_asset')) {
|
||||
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
|
||||
->update(['location_id' => $target->location_id]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if ($updated) {
|
||||
|
||||
if ($asset->image) {
|
||||
$asset->image = $asset->getImageUrl();
|
||||
}
|
||||
@@ -820,6 +875,36 @@ 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).
|
||||
*
|
||||
@@ -896,6 +981,7 @@ class AssetsController extends Controller
|
||||
*/
|
||||
public function checkoutByTag(AssetCheckoutRequest $request, $tag): JsonResponse
|
||||
{
|
||||
// Use the same hardened checkout path as ID-based checkout.
|
||||
if ($asset = Asset::where('asset_tag', $tag)->first()) {
|
||||
return $this->checkout($request, $asset->id);
|
||||
}
|
||||
@@ -931,19 +1017,22 @@ class AssetsController extends Controller
|
||||
|
||||
// This item is checked out to a location
|
||||
if (request('checkout_to_type') == 'location') {
|
||||
$target = Location::find(request('assigned_location'));
|
||||
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
|
||||
$target = Location::withoutGlobalScopes()->find(request('assigned_location'));
|
||||
$asset->location_id = ($target) ? $target->id : '';
|
||||
$error_payload['target_id'] = $request->input('assigned_location');
|
||||
$error_payload['target_type'] = 'location';
|
||||
} elseif (request('checkout_to_type') == 'asset') {
|
||||
$target = Asset::where('id', '!=', $asset_id)->find(request('assigned_asset'));
|
||||
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
|
||||
$target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
|
||||
// Override with the asset's location_id if it has one
|
||||
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
|
||||
$error_payload['target_id'] = $request->input('assigned_asset');
|
||||
$error_payload['target_type'] = 'asset';
|
||||
} elseif (request('checkout_to_type') == 'user') {
|
||||
// Fetch the target and set the asset's new location_id
|
||||
$target = User::find(request('assigned_user'));
|
||||
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
|
||||
$target = User::withoutGlobalScopes()->find(request('assigned_user'));
|
||||
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
|
||||
$error_payload['target_id'] = $request->input('assigned_user');
|
||||
$error_payload['target_type'] = 'user';
|
||||
@@ -953,10 +1042,25 @@ class AssetsController extends Controller
|
||||
$asset->status_id = $request->input('status_id');
|
||||
}
|
||||
|
||||
// Preserve existing requestable state unless API caller explicitly includes the field.
|
||||
if ($request->has('requestable')) {
|
||||
$asset->requestable = $request->boolean('requestable');
|
||||
}
|
||||
|
||||
if (! isset($target)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
|
||||
}
|
||||
|
||||
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
|
||||
$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);
|
||||
@@ -971,7 +1075,12 @@ class AssetsController extends Controller
|
||||
// $asset->location_id = $target->rtd_location_id;
|
||||
// }
|
||||
|
||||
if ($asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id)) {
|
||||
// Keep checkout mutation + checkout logging/event side effects atomic.
|
||||
$wasCheckedOut = DB::transaction(function () use ($asset, $target, $checkout_at, $expected_checkin, $note, $asset_name): bool {
|
||||
return $asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id);
|
||||
});
|
||||
|
||||
if ($wasCheckedOut) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
|
||||
}
|
||||
|
||||
@@ -1053,6 +1162,12 @@ class AssetsController extends Controller
|
||||
});
|
||||
|
||||
if ($asset->save()) {
|
||||
|
||||
// Update the location of any child assets
|
||||
Asset::where('assigned_type', Asset::class)
|
||||
->where('assigned_to', $asset->id)
|
||||
->update(['location_id' => $asset->location_id]);
|
||||
|
||||
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', [
|
||||
@@ -1109,11 +1224,23 @@ class AssetsController extends Controller
|
||||
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
|
||||
}
|
||||
|
||||
// Allow the asset tag to be passed in the payload (legacy method)
|
||||
if ($request->filled('asset_tag')) {
|
||||
$audit_by_field = $request->input('audit_by_field', 'asset_tag');
|
||||
$audit_key = $request->input('audit_key', null);
|
||||
|
||||
// If they have selected to scan by serial, use that
|
||||
if (($settings->unique_serial == '1') && ($audit_by_field == 'serial') && ($audit_key)) {
|
||||
$asset = Asset::where('serial', '=', trim($audit_key))->first();
|
||||
|
||||
// If they have selected by asset tag, use that
|
||||
} elseif (($audit_by_field == 'asset_tag') && ($audit_key)) {
|
||||
$asset = Asset::where('asset_tag', '=', trim($audit_key))->first();
|
||||
|
||||
// Allow the asset tag to be passed in the payload (legacy method)
|
||||
} elseif ($request->filled('asset_tag')) {
|
||||
$asset = Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
|
||||
}
|
||||
|
||||
// If none of the above were selected, fall back to the route-model-binding
|
||||
if ($asset) {
|
||||
|
||||
$originalValues = $asset->getRawOriginal();
|
||||
@@ -1135,8 +1262,10 @@ class AssetsController extends Controller
|
||||
// Set up the payload for re-display in the API response
|
||||
$payload = [
|
||||
'id' => $asset->id,
|
||||
'asset_tag' => $asset->asset_tag,
|
||||
'note' => e($request->input('note')),
|
||||
'asset_tag' => e($asset->asset_tag),
|
||||
'audit_by_field' => e(Str::headline($audit_by_field)),
|
||||
'audit_key' => e($audit_key),
|
||||
'note' => $request->filled('note') ? e($request->input('note')) : null,
|
||||
'status_label' => e($asset->status?->display_name),
|
||||
'status_type' => $asset->status?->getStatuslabelType(),
|
||||
'next_audit_date' => Helper::getFormattedDateObject($asset->next_audit_date),
|
||||
@@ -1177,7 +1306,7 @@ class AssetsController extends Controller
|
||||
|
||||
// Validate the rest of the data before we turn off the event dispatcher
|
||||
if ($asset->isInvalid()) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', ['asset_tag' => $asset->asset_tag], $asset->getErrors()));
|
||||
return response()->json(Helper::formatStandardApiResponse('error', $payload, $asset->getErrors()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1210,8 +1339,13 @@ class AssetsController extends Controller
|
||||
|
||||
}
|
||||
|
||||
$fail_payload = [
|
||||
'audit_by_field' => e(Str::headline($audit_by_field)),
|
||||
'audit_key' => e($audit_key),
|
||||
];
|
||||
|
||||
// No matching asset for the asset tag that was passed.
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
|
||||
return response()->json(Helper::formatStandardApiResponse('error', $fail_payload, trans('admin/hardware/message.does_not_exist')), 200);
|
||||
|
||||
}
|
||||
|
||||
@@ -1411,7 +1545,7 @@ class AssetsController extends Controller
|
||||
$label = new Label;
|
||||
|
||||
if (! $label) {
|
||||
throw new \Exception('Label object could not be created');
|
||||
throw new \Exception(trans('admin/labels/message.label_not_created'));
|
||||
}
|
||||
|
||||
// Configure label with assets and settings
|
||||
@@ -1432,7 +1566,7 @@ class AssetsController extends Controller
|
||||
|
||||
// Verify PDF was generated successfully
|
||||
if (empty($pdf_content)) {
|
||||
throw new \Exception('PDF content is empty');
|
||||
throw new \Exception(trans('admin/labels/message.use_new_label_engine_for_api'));
|
||||
}
|
||||
|
||||
$encoded_content = base64_encode($pdf_content);
|
||||
@@ -1460,11 +1594,11 @@ class AssetsController extends Controller
|
||||
public function history(Request $request, Asset $asset): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $asset);
|
||||
$history = $asset->getHistory($request);
|
||||
$total = $asset->getHistory($request)->count();
|
||||
$historyQuery = $asset->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = $history->skip($offset)->take($limit)->get();
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,11 @@ class CategoriesController extends Controller
|
||||
case 'created_by':
|
||||
$categories = $categories->OrderByCreatedBy($order);
|
||||
break;
|
||||
// This is annoying, since it's not a real relationship, which is what we usually use these switches for, but
|
||||
// we call the field has_eula, not eula_text, so there won't be a matching field
|
||||
case 'has_eula':
|
||||
$categories = $categories->orderBy('eula_text', $order);
|
||||
break;
|
||||
default:
|
||||
$categories = $categories->orderBy($column_sort, $order);
|
||||
break;
|
||||
|
||||
@@ -9,8 +9,9 @@ use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\ComponentsTransformer;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use App\Models\ComponentAssignment;
|
||||
use App\Models\Setting;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -80,7 +81,7 @@ class ComponentsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
$components->where('name', '=', $request->input('name'));
|
||||
$components->where('components.name', '=', $request->input('name'));
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
@@ -92,27 +93,27 @@ class ComponentsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$components->where('category_id', '=', $request->input('category_id'));
|
||||
$components->where('components.category_id', '=', $request->input('category_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('supplier_id')) {
|
||||
$components->where('supplier_id', '=', $request->input('supplier_id'));
|
||||
$components->where('components.supplier_id', '=', $request->input('supplier_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('manufacturer_id')) {
|
||||
$components->where('manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
$components->where('components.manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('model_number')) {
|
||||
$components->where('model_number', '=', $request->input('model_number'));
|
||||
$components->where('components.model_number', '=', $request->input('model_number'));
|
||||
}
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
$components->where('location_id', '=', $request->input('location_id'));
|
||||
$components->where('components.location_id', '=', $request->input('location_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('notes')) {
|
||||
$components->where('notes', '=', $request->input('notes'));
|
||||
$components->where('components.notes', '=', $request->input('notes'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
@@ -166,6 +167,7 @@ class ComponentsController extends Controller
|
||||
$this->authorize('create', Component::class);
|
||||
$component = new Component;
|
||||
$component->fill($request->all());
|
||||
$component->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$component = $request->handleImages($component);
|
||||
|
||||
if ($component->save()) {
|
||||
@@ -206,6 +208,7 @@ class ComponentsController extends Controller
|
||||
$this->authorize('update', Component::class);
|
||||
$component = Component::findOrFail($id);
|
||||
$component->fill($request->all());
|
||||
$component->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$component = $request->handleImages($component);
|
||||
|
||||
if ($component->save()) {
|
||||
@@ -252,13 +255,11 @@ class ComponentsController extends Controller
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
|
||||
$component_checkouts = ComponentAssignment::where('component_id', $component->id)->with('adminuser')->with('assets');
|
||||
|
||||
$offset = request('offset', 0);
|
||||
$limit = $request->input('limit', 50);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$assets = $component_checkouts->assets()
|
||||
$assets = $component->assets()
|
||||
->where(function ($query) use ($request) {
|
||||
$search_str = '%'.$request->input('search').'%';
|
||||
$query->where('name', 'like', $search_str)
|
||||
@@ -314,20 +315,33 @@ class ComponentsController extends Controller
|
||||
}
|
||||
|
||||
if ($component->numRemaining() >= $request->input('assigned_qty')) {
|
||||
// Resolve the raw target first, then enforce FMCS explicitly.
|
||||
// Scoped lookup can hide cross-company records and lead to partial writes.
|
||||
$asset = Asset::withoutGlobalScopes()->find($request->input('assigned_to'));
|
||||
|
||||
$asset = Asset::find($request->input('assigned_to'));
|
||||
$component->assigned_to = $request->input('assigned_to');
|
||||
if (! $asset) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
|
||||
}
|
||||
|
||||
$component->assets()->attach($component->id, [
|
||||
'component_id' => $component->id,
|
||||
'created_at' => Carbon::now(),
|
||||
'assigned_qty' => $request->input('assigned_qty', 1),
|
||||
'created_by' => auth()->id(),
|
||||
'asset_id' => $request->input('assigned_to'),
|
||||
'note' => $request->input('note'),
|
||||
]);
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($component->company_id !== $asset->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
|
||||
// Keep pivot + action log in one transaction so checkout is all-or-nothing.
|
||||
DB::transaction(function () use ($component, $request, $asset): void {
|
||||
$component->assigned_to = $request->input('assigned_to');
|
||||
|
||||
$component->assets()->attach($component->id, [
|
||||
'component_id' => $component->id,
|
||||
'created_at' => Carbon::now(),
|
||||
'assigned_qty' => $request->input('assigned_qty', 1),
|
||||
'created_by' => auth()->id(),
|
||||
'asset_id' => $request->input('assigned_to'),
|
||||
'note' => $request->input('note'),
|
||||
]);
|
||||
|
||||
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
|
||||
});
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkout.success')));
|
||||
}
|
||||
@@ -391,11 +405,11 @@ class ComponentsController extends Controller
|
||||
public function history(Request $request, Component $component): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $component);
|
||||
$history = $component->getHistory($request);
|
||||
$total = $component->getHistory($request)->count();
|
||||
$historyQuery = $component->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = $history->skip($offset)->take($limit)->get();
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ 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
|
||||
{
|
||||
@@ -67,7 +69,7 @@ class ConsumablesController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
$consumables->where('name', '=', $request->input('name'));
|
||||
$consumables->where('consumables.name', '=', $request->input('name'));
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
@@ -79,27 +81,27 @@ class ConsumablesController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$consumables->where('category_id', '=', $request->input('category_id'));
|
||||
$consumables->where('consumables.category_id', '=', $request->input('category_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('model_number')) {
|
||||
$consumables->where('model_number', '=', $request->input('model_number'));
|
||||
$consumables->where('consumables.model_number', '=', $request->input('model_number'));
|
||||
}
|
||||
|
||||
if ($request->filled('manufacturer_id')) {
|
||||
$consumables->where('manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
$consumables->where('consumables.manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('supplier_id')) {
|
||||
$consumables->where('supplier_id', '=', $request->input('supplier_id'));
|
||||
$consumables->where('consumables.supplier_id', '=', $request->input('supplier_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
$consumables->where('location_id', '=', $request->input('location_id'));
|
||||
$consumables->where('consumables.location_id', '=', $request->input('location_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('notes')) {
|
||||
$consumables->where('notes', '=', $request->input('notes'));
|
||||
$consumables->where('consumables.notes', '=', $request->input('notes'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
@@ -155,6 +157,7 @@ class ConsumablesController extends Controller
|
||||
$this->authorize('create', Consumable::class);
|
||||
$consumable = new Consumable;
|
||||
$consumable->fill($request->all());
|
||||
$consumable->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$consumable = $request->handleImages($consumable);
|
||||
|
||||
if ($consumable->save()) {
|
||||
@@ -194,6 +197,7 @@ class ConsumablesController extends Controller
|
||||
$this->authorize('update', Consumable::class);
|
||||
$consumable = Consumable::findOrFail($id);
|
||||
$consumable->fill($request->all());
|
||||
$consumable->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$consumable = $request->handleImages($consumable);
|
||||
|
||||
if ($consumable->save()) {
|
||||
@@ -304,34 +308,42 @@ class ConsumablesController extends Controller
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining()])));
|
||||
}
|
||||
|
||||
// Check if the user exists - @TODO: this should probably be handled via validation, not here??
|
||||
if (! $user = User::find($request->input('assigned_to'))) {
|
||||
// Resolve the raw target first, then enforce FMCS explicitly.
|
||||
// Scoped lookup can hide cross-company users and make failures ambiguous.
|
||||
if (! $user = User::withoutGlobalScopes()->find($request->input('assigned_to'))) {
|
||||
// Return error message
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
|
||||
}
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($consumable->company_id !== $user->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
// Update the consumable data
|
||||
$consumable->assigned_to = $request->input('assigned_to');
|
||||
|
||||
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
|
||||
$consumable->users()->attach($consumable->id,
|
||||
[
|
||||
'consumable_id' => $consumable->id,
|
||||
'created_by' => $user->id,
|
||||
'assigned_to' => $request->input('assigned_to'),
|
||||
'note' => $request->input('note'),
|
||||
]
|
||||
);
|
||||
}
|
||||
// Keep pivot writes and checkout log/event atomic to avoid partial checkout state.
|
||||
DB::transaction(function () use ($consumable, $request, $user): void {
|
||||
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
|
||||
$consumable->users()->attach($consumable->id,
|
||||
[
|
||||
'consumable_id' => $consumable->id,
|
||||
'created_by' => $user->id,
|
||||
'assigned_to' => $request->input('assigned_to'),
|
||||
'note' => $request->input('note'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
event(new CheckoutableCheckedOut(
|
||||
$consumable,
|
||||
$user,
|
||||
auth()->user(),
|
||||
$request->input('note'),
|
||||
[],
|
||||
$consumable->checkout_qty,
|
||||
));
|
||||
event(new CheckoutableCheckedOut(
|
||||
$consumable,
|
||||
$user,
|
||||
auth()->user(),
|
||||
$request->input('note'),
|
||||
[],
|
||||
$consumable->checkout_qty,
|
||||
));
|
||||
});
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success')));
|
||||
|
||||
@@ -361,11 +373,11 @@ class ConsumablesController extends Controller
|
||||
public function history(Request $request, Consumable $consumable): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $consumable);
|
||||
$history = $consumable->getHistory($request);
|
||||
$total = $consumable->getHistory($request)->count();
|
||||
$historyQuery = $consumable->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = $history->skip($offset)->take($limit)->get();
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\StoreDepartmentRequest;
|
||||
use App\Http\Transformers\DepartmentsTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Company;
|
||||
use App\Models\Department;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -50,23 +51,23 @@ class DepartmentsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
$departments->where('name', '=', $request->input('name'));
|
||||
$departments->where('departments.name', '=', $request->input('name'));
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
$departments->where('company_id', '=', $request->input('company_id'));
|
||||
$departments->where('departments.company_id', '=', $request->input('company_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('manager_id')) {
|
||||
$departments->where('manager_id', '=', $request->input('manager_id'));
|
||||
$departments->where('departments.manager_id', '=', $request->input('manager_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
$departments->where('location_id', '=', $request->input('location_id'));
|
||||
$departments->where('departments.location_id', '=', $request->input('location_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('tag_color')) {
|
||||
$departments->where('tag_color', '=', $request->input('departments.tag_color'));
|
||||
$departments->where('departments.tag_color', '=', $request->input('tag_color'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
@@ -111,6 +112,7 @@ class DepartmentsController extends Controller
|
||||
{
|
||||
$department = new Department;
|
||||
$department->fill($request->validated());
|
||||
$department->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$department = $request->handleImages($department);
|
||||
|
||||
$department->created_by = auth()->id();
|
||||
@@ -155,6 +157,7 @@ class DepartmentsController extends Controller
|
||||
$this->authorize('update', Department::class);
|
||||
$department = Department::findOrFail($id);
|
||||
$department->fill($request->all());
|
||||
$department->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$department = $request->handleImages($department);
|
||||
|
||||
if ($department->save()) {
|
||||
|
||||
@@ -8,9 +8,11 @@ use App\Http\Transformers\LicenseSeatsTransformer;
|
||||
use App\Models\Asset;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LicenseSeatsController extends Controller
|
||||
{
|
||||
@@ -106,7 +108,8 @@ class LicenseSeatsController extends Controller
|
||||
'prohibits:asset_id',
|
||||
// must be a valid user or null to unassign
|
||||
function ($attribute, $value, $fail) {
|
||||
if (! is_null($value) && ! User::where('id', $value)->whereNull('deleted_at')->exists()) {
|
||||
// Validate existence without company scopes; FMCS checks happen explicitly below.
|
||||
if (! is_null($value) && ! User::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
|
||||
$fail('The selected assigned_to is invalid.');
|
||||
}
|
||||
},
|
||||
@@ -118,7 +121,8 @@ class LicenseSeatsController extends Controller
|
||||
'prohibits:assigned_to',
|
||||
// must be a valid asset or null to unassign
|
||||
function ($attribute, $value, $fail) {
|
||||
if (! is_null($value) && ! Asset::where('id', $value)->whereNull('deleted_at')->exists()) {
|
||||
// Validate existence without company scopes; FMCS checks happen explicitly below.
|
||||
if (! is_null($value) && ! Asset::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
|
||||
$fail('The selected asset_id is invalid.');
|
||||
}
|
||||
},
|
||||
@@ -139,6 +143,34 @@ class LicenseSeatsController extends Controller
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
|
||||
}
|
||||
|
||||
$targetUser = null;
|
||||
if (! is_null($request->input('assigned_to'))) {
|
||||
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
|
||||
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
|
||||
|
||||
if (! $targetUser) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
|
||||
}
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetUser->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
}
|
||||
|
||||
$targetAsset = null;
|
||||
if (! is_null($request->input('asset_id'))) {
|
||||
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
|
||||
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
|
||||
|
||||
if (! $targetAsset) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
|
||||
}
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
}
|
||||
|
||||
$oldUser = $licenseSeat->user;
|
||||
$oldAsset = $licenseSeat->asset;
|
||||
|
||||
@@ -166,11 +198,11 @@ class LicenseSeatsController extends Controller
|
||||
// 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);
|
||||
$target = $is_checkin ? $oldUser : $targetUser;
|
||||
}
|
||||
|
||||
if ($licenseSeat->isDirty('asset_id')) {
|
||||
$target = $is_checkin ? $oldAsset : Asset::find($licenseSeat->asset_id);
|
||||
$target = $is_checkin ? $oldAsset : $targetAsset;
|
||||
}
|
||||
|
||||
if ($assignmentTouched && is_null($target)) {
|
||||
@@ -181,13 +213,22 @@ class LicenseSeatsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($licenseSeat->save()) {
|
||||
// Keep seat updates and checkout/checkin logging atomic to prevent partial state changes.
|
||||
$updated = DB::transaction(function () use ($licenseSeat, $assignmentTouched, $is_checkin, $target, $request): bool {
|
||||
if (! $licenseSeat->save()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($assignmentTouched) {
|
||||
if ($is_checkin) {
|
||||
if (! $licenseSeat->license->reassignable) {
|
||||
$licenseSeat->unreassignable_seat = true;
|
||||
$licenseSeat->save();
|
||||
|
||||
if (! $licenseSeat->save()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: skip if target is null?
|
||||
$licenseSeat->logCheckin($target, $licenseSeat->notes);
|
||||
} else {
|
||||
@@ -196,6 +237,10 @@ class LicenseSeatsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if ($updated) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\LicensesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Company;
|
||||
use App\Models\License;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -27,7 +28,7 @@ class LicensesController extends Controller
|
||||
{
|
||||
$this->authorize('view', License::class);
|
||||
|
||||
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser')->withCount('freeSeats as free_seats_count');
|
||||
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser', 'licenseSeatsRelation', 'assignedCount')->withCount('freeSeats as free_seats_count');
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
if ($request->input('status') == 'inactive') {
|
||||
@@ -179,6 +180,7 @@ class LicensesController extends Controller
|
||||
$this->authorize('create', License::class);
|
||||
$license = new License;
|
||||
$license->fill($request->all());
|
||||
$license->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
|
||||
if ($license->save()) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $license, trans('admin/licenses/message.create.success')));
|
||||
@@ -219,6 +221,7 @@ class LicensesController extends Controller
|
||||
|
||||
$license = License::findOrFail($id);
|
||||
$license->fill($request->all());
|
||||
$license->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
|
||||
if ($license->save()) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $license, trans('admin/licenses/message.update.success')));
|
||||
@@ -244,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('id', $license->id)
|
||||
->where('license_id', $license->id)
|
||||
->update(['assigned_to' => null, 'asset_id' => null]);
|
||||
|
||||
$licenseSeats = $license->licenseseats();
|
||||
@@ -282,11 +285,11 @@ class LicensesController extends Controller
|
||||
public function history(Request $request, License $license): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $license);
|
||||
$history = $license->getHistory($request);
|
||||
$total = $license->getHistory($request)->count();
|
||||
$historyQuery = $license->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = $history->skip($offset)->take($limit)->get();
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -462,11 +462,11 @@ class LocationsController extends Controller
|
||||
public function history(Request $request, Location $location): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $location);
|
||||
$history = $location->getHistory($request);
|
||||
$total = $location->getHistory($request)->count();
|
||||
$historyQuery = $location->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = $history->skip($offset)->take($limit)->get();
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -257,14 +257,12 @@ class MaintenancesController extends Controller
|
||||
|
||||
public function history(Request $request, Maintenance $maintenance): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
$asset = $maintenance->asset;
|
||||
$this->authorize('history', $asset);
|
||||
$history = $maintenance->getHistory($request);
|
||||
$total = $maintenance->getHistory($request)->count();
|
||||
$this->authorize('history', $maintenance);
|
||||
$historyQuery = $maintenance->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = $history->skip($offset)->take($limit)->get();
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,18 @@ 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
|
||||
@@ -125,4 +135,141 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,13 @@ class SettingsController extends Controller
|
||||
public function ajaxTestEmail(): JsonResponse
|
||||
{
|
||||
if (! config('app.lock_passwords')) {
|
||||
|
||||
if (config('mail.reply_to.address') == '') {
|
||||
Log::debug('MAIL_REPLYTO_ADDR not set in env. Skipping mail test.');
|
||||
|
||||
return response()->json(['message' => trans('admin/settings/general.mail_test_no_email')], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
Notification::send(Setting::first(), new MailTest);
|
||||
Log::debug('Attempting to sending to '.config('mail.reply_to.address'));
|
||||
@@ -286,6 +293,11 @@ class SettingsController extends Controller
|
||||
*/
|
||||
public function downloadBackup($file): JsonResponse|BinaryFileResponse
|
||||
{
|
||||
$file = $this->sanitizeBackupFilename($file);
|
||||
|
||||
if ($file === null) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_not_found')), 404);
|
||||
}
|
||||
|
||||
$path = storage_path('app/backups');
|
||||
|
||||
@@ -329,4 +341,21 @@ class SettingsController extends Controller
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function sanitizeBackupFilename(mixed $filename): ?string
|
||||
{
|
||||
$filename = trim((string) $filename);
|
||||
|
||||
if ($filename === '' || str_contains($filename, "\0")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sanitized = basename($filename);
|
||||
|
||||
if (($sanitized === '') || ($sanitized === '.') || ($sanitized === '..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($sanitized === $filename) ? $sanitized : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,21 +808,27 @@ class UsersController extends Controller
|
||||
try {
|
||||
$user = User::find($request->input('id'));
|
||||
$this->authorize('update', $user);
|
||||
$user->two_factor_secret = null;
|
||||
$user->two_factor_enrolled = 0;
|
||||
$user->saveQuietly();
|
||||
|
||||
// Log the reset
|
||||
$logaction = new Actionlog;
|
||||
$logaction->target_type = User::class;
|
||||
$logaction->target_id = $user->id;
|
||||
$logaction->item_type = User::class;
|
||||
$logaction->item_id = $user->id;
|
||||
$logaction->created_at = date('Y-m-d H:i:s');
|
||||
$logaction->created_by = auth()->id();
|
||||
$logaction->logaction('2FA reset');
|
||||
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
|
||||
|
||||
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
|
||||
$user->two_factor_secret = null;
|
||||
$user->two_factor_enrolled = 0;
|
||||
$user->saveQuietly();
|
||||
|
||||
// Log the reset
|
||||
$logaction = new Actionlog;
|
||||
$logaction->target_type = User::class;
|
||||
$logaction->target_id = $user->id;
|
||||
$logaction->item_type = User::class;
|
||||
$logaction->item_id = $user->id;
|
||||
$logaction->created_at = date('Y-m-d H:i:s');
|
||||
$logaction->created_by = auth()->id();
|
||||
$logaction->logaction('2FA reset');
|
||||
|
||||
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
|
||||
}
|
||||
|
||||
return response()->json(['message' => trans('general.unauthorized')], 500);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_error')], 500);
|
||||
}
|
||||
@@ -939,11 +945,11 @@ class UsersController extends Controller
|
||||
public function history(Request $request, User $user): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $user);
|
||||
$history = $user->getHistory($request);
|
||||
$total = $user->getHistory($request)->count();
|
||||
$historyQuery = $user->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = $history->skip($offset)->take($limit)->get();
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Traits\MigratesLegacyAssetLocations;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Statuslabel;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -56,9 +57,16 @@ class AssetCheckinController extends Controller
|
||||
default => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.user')]),
|
||||
};
|
||||
|
||||
$deployableStatusIds = array_map('intval', array_keys(Helper::deployableStatusLabelList()));
|
||||
$selectedStatusId = old('status_id');
|
||||
$showRequestableToggle = is_numeric($selectedStatusId)
|
||||
&& in_array((int) $selectedStatusId, $deployableStatusIds, true);
|
||||
|
||||
return view('hardware/checkin', compact('asset', 'target_option'))
|
||||
->with('item', $asset)
|
||||
->with('statusLabel_list', Helper::statusLabelList())
|
||||
->with('deployable_status_ids', $deployableStatusIds)
|
||||
->with('show_requestable_toggle', $showRequestableToggle)
|
||||
->with('backto', $backto)
|
||||
->with('table_name', 'Assets');
|
||||
}
|
||||
@@ -107,6 +115,19 @@ class AssetCheckinController extends Controller
|
||||
$asset->status_id = e($request->input('status_id'));
|
||||
}
|
||||
|
||||
$selectedStatusId = $request->filled('status_id')
|
||||
? (int) $request->input('status_id')
|
||||
: (int) $asset->status_id;
|
||||
|
||||
$isDeployableStatus = Statuslabel::query()
|
||||
->whereKey($selectedStatusId)
|
||||
->where('deployable', 1)
|
||||
->exists();
|
||||
|
||||
if ($request->boolean('set_requestable') && $isDeployableStatus) {
|
||||
$asset->requestable = true;
|
||||
}
|
||||
|
||||
// Add any custom fields that should be included in the checkout
|
||||
$asset->customFieldsForCheckinCheckout('display_checkin');
|
||||
|
||||
@@ -154,6 +175,10 @@ class AssetCheckinController extends Controller
|
||||
$asset->customFieldsForCheckinCheckout('display_checkin');
|
||||
|
||||
if ($asset->save()) {
|
||||
// Update the location of any child assets
|
||||
Asset::where('assigned_type', Asset::class)
|
||||
->where('assigned_to', $asset->id)
|
||||
->update(['location_id' => $asset->location_id]);
|
||||
|
||||
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));
|
||||
|
||||
@@ -164,4 +189,34 @@ class AssetCheckinController extends Controller
|
||||
// Redirect to the asset management page with error
|
||||
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.checkin.error').$asset->getErrors());
|
||||
}
|
||||
|
||||
/**
|
||||
* This would only be used if the target is actually hard-deleted
|
||||
* and literally does not exist in the database anymore. This will null out the assigned_to
|
||||
* and assigned_type fields, but will not trigger any events or do any of the other things that a
|
||||
* normal checkin would do, since the target itself is now invalid.
|
||||
*/
|
||||
public function forceCheckin(Asset $asset)
|
||||
{
|
||||
|
||||
$this->authorize('checkin', $asset);
|
||||
|
||||
if (! $asset->hasOrphanedAssignment()) {
|
||||
return redirect()->route('hardware.show', $asset->id)
|
||||
->with('error', trans('admin/hardware/message.checkin.force_checkin_not_orphaned'));
|
||||
}
|
||||
|
||||
$asset->assigned_to = null;
|
||||
$asset->assigned_type = null;
|
||||
|
||||
if ($asset->save()) {
|
||||
$asset->logForceCheckin();
|
||||
|
||||
return redirect()->route('hardware.show', $asset->id)
|
||||
->with('success', trans('admin/hardware/message.checkin.force_checkin_orphaned_success'));
|
||||
}
|
||||
|
||||
return redirect()->route('hardware.show', $asset->id)
|
||||
->with('error', trans('admin/hardware/message.checkin.force_checkin_error'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ use App\Http\Controllers\CheckInOutRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AssetCheckoutRequest;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -101,6 +103,10 @@ class AssetCheckoutController extends Controller
|
||||
$asset->status_id = $request->input('status_id');
|
||||
}
|
||||
|
||||
if ($request->boolean('set_not_requestable')) {
|
||||
$asset->requestable = false;
|
||||
}
|
||||
|
||||
if (! empty($asset->licenseseats->all())) {
|
||||
if (request('checkout_to_type') == 'user') {
|
||||
foreach ($asset->licenseseats as $seat) {
|
||||
@@ -122,9 +128,43 @@ class AssetCheckoutController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => $request->input('checkout_to_type'),
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
|
||||
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'), null, $request->boolean('sign_in_place'))) {
|
||||
|
||||
// When sign_in_place is requested and the target is a user, redirect to the
|
||||
// acceptance/signature page so the user can sign in person. The signature is
|
||||
// attributed to the target user, not the admin.
|
||||
if ($request->boolean('sign_in_place') && $target instanceof User) {
|
||||
$acceptance = CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->where('assigned_to_id', $target->id)
|
||||
->pending()
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// If requireAcceptance() is false the listener won't have created one; create it now.
|
||||
if (! $acceptance) {
|
||||
$acceptance = new CheckoutAcceptance;
|
||||
$acceptance->checkoutable()->associate($asset);
|
||||
$acceptance->assignedTo()->associate($target);
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
session([
|
||||
'sign_in_place_acceptance_id' => $acceptance->id,
|
||||
'sign_in_place_item_id' => $asset->id,
|
||||
'sign_in_place_resource_type' => 'Assets',
|
||||
]);
|
||||
|
||||
return redirect()->route('account.accept.item', $acceptance->id)
|
||||
->with('success', trans('admin/hardware/message.checkout.success'));
|
||||
}
|
||||
|
||||
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'))) {
|
||||
return Helper::getRedirectOption($request, $asset->id, 'Assets')
|
||||
->with('success', trans('admin/hardware/message.checkout.success'));
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ class AssetsController extends Controller
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('index', Asset::class);
|
||||
$company = Company::find($request->input('company_id'));
|
||||
$companyId = $request->input('company_id');
|
||||
$company = is_scalar($companyId) ? Company::find($companyId) : null;
|
||||
|
||||
return view('hardware/index')->with('company', $company);
|
||||
}
|
||||
@@ -360,8 +361,23 @@ class AssetsController extends Controller
|
||||
'url' => route('qr_code/hardware', $asset),
|
||||
];
|
||||
|
||||
$total_maintenance_cost = $asset->maintenances?->sum('cost');
|
||||
$total_asset_cost = ($asset->assignedAssets()?->AssetsForShow()) ? $asset->assignedAssets()?->AssetsForShow()?->sum('purchase_cost') : 0;
|
||||
$total_license_cost = ($asset->licenses) ? $asset->licenses->sum('purchase_cost') : 0;
|
||||
$total_accessory_cost = ($asset->accessories) ? $asset->accessories()->sum('purchase_cost') : 0;
|
||||
$total_component_cost = ($asset->components) ? $asset->components->sum('calculated_purchase_cost') : 0;
|
||||
|
||||
$total_cost_for_asset = $asset->purchase_cost + $total_maintenance_cost + $total_asset_cost + $total_license_cost + $total_accessory_cost + $total_component_cost;
|
||||
|
||||
return view('hardware/view', compact('asset', 'qr_code', 'settings'))
|
||||
->with('use_currency', $use_currency)->with('audit_log', $audit_log);
|
||||
->with('total_maintenance_cost', $total_maintenance_cost)
|
||||
->with('total_asset_cost', $total_asset_cost)
|
||||
->with('total_license_cost', $total_license_cost)
|
||||
->with('total_accessory_cost', $total_accessory_cost)
|
||||
->with('total_component_cost', $total_component_cost)
|
||||
->with('total_cost_for_asset', $total_cost_for_asset)
|
||||
->with('use_currency', $use_currency)
|
||||
->with('audit_log', $audit_log);
|
||||
}
|
||||
|
||||
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
|
||||
@@ -716,6 +716,10 @@ class BulkAssetsController extends Controller
|
||||
$asset->status_id = $request->input('status_id');
|
||||
}
|
||||
|
||||
if ($request->boolean('set_not_requestable')) {
|
||||
$asset->requestable = false;
|
||||
}
|
||||
|
||||
$checkout_success = $asset->checkOut($target, $admin, $checkout_at, $expected_checkin, e($request->input('note')), $asset->name, null);
|
||||
|
||||
// TODO - I think this logic is duplicated in the checkOut method?
|
||||
|
||||
@@ -106,15 +106,21 @@ class LoginController extends Controller
|
||||
if ($saml->isEnabled() && ! empty($samlData)) {
|
||||
|
||||
try {
|
||||
|
||||
$user = $saml->samlLogin($samlData);
|
||||
$notValidAfter = new \Carbon\Carbon(@$samlData['assertionNotOnOrAfter']);
|
||||
if (\Carbon::now()->greaterThanOrEqualTo($notValidAfter)) {
|
||||
abort(400, 'Expired SAML Assertion');
|
||||
}
|
||||
if (SamlNonce::where('nonce', @$samlData['nonce'])->count() > 0) {
|
||||
abort(400, 'Assertion has already been used');
|
||||
try {
|
||||
SamlNonce::create([
|
||||
'nonce' => $samlData['nonce'],
|
||||
'not_valid_after' => $notValidAfter,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error($e);
|
||||
abort(400, 'Assertion has already been used.');
|
||||
}
|
||||
Log::debug('okay, fine, this is a new nonce then. Good for you.');
|
||||
if (! is_null($user)) {
|
||||
Auth::login($user);
|
||||
} else {
|
||||
@@ -128,10 +134,6 @@ class LoginController extends Controller
|
||||
$user->last_login = \Carbon::now();
|
||||
$user->saveQuietly();
|
||||
}
|
||||
$s = new SamlNonce;
|
||||
$s->nonce = @$samlData['nonce'];
|
||||
$s->not_valid_after = $notValidAfter;
|
||||
$s->save();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::debug('There was an error authenticating the SAML user: '.$e->getMessage());
|
||||
@@ -433,7 +435,7 @@ class LoginController extends Controller
|
||||
$user->saveQuietly();
|
||||
$request->session()->put('2fa_authed', $user->id);
|
||||
|
||||
return redirect()->route('home')->with('success', trans('auth/message.signin.success'));
|
||||
return redirect()->intended()->with('success', trans('auth/message.signin.success'));
|
||||
}
|
||||
|
||||
return redirect()->route('two-factor')->with('error', trans('auth/message.two_factor.invalid_code'));
|
||||
|
||||
@@ -74,8 +74,7 @@ class SamlController extends Controller
|
||||
public function login(Request $request)
|
||||
{
|
||||
$auth = $this->saml->getAuth();
|
||||
$ssoUrl = $auth->login(null, [], false, false, false, false);
|
||||
|
||||
$ssoUrl = $auth->login(session()->get('url.intended'), [], false, false, false, false);
|
||||
return redirect()->away($ssoUrl);
|
||||
}
|
||||
|
||||
@@ -96,6 +95,7 @@ class SamlController extends Controller
|
||||
$saml = $this->saml;
|
||||
$auth = $saml->getAuth();
|
||||
$saml_exception = false;
|
||||
session()->put('url.intended', $request->post('RelayState'));
|
||||
try {
|
||||
$auth->processResponse();
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Consumables;
|
||||
use App\Events\CheckoutableCheckedOut;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -116,12 +117,46 @@ class ConsumableCheckoutController extends Controller
|
||||
$request->input('note'),
|
||||
[],
|
||||
$consumable->checkout_qty,
|
||||
$request->boolean('sign_in_place'),
|
||||
));
|
||||
|
||||
$request->request->add(['checkout_to_type' => 'user']);
|
||||
$request->request->add(['assigned_user' => $user->id]);
|
||||
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => $request->input('checkout_to_type'),
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
|
||||
// When sign_in_place is requested, redirect to the acceptance/signature page
|
||||
// so the user can sign in person. The signature is attributed to the target user.
|
||||
if ($request->boolean('sign_in_place')) {
|
||||
$acceptance = CheckoutAcceptance::where('checkoutable_type', Consumable::class)
|
||||
->where('checkoutable_id', $consumable->id)
|
||||
->where('assigned_to_id', $user->id)
|
||||
->pending()
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// If requireAcceptance() is false the listener won't have created one; create it now.
|
||||
if (! $acceptance) {
|
||||
$acceptance = new CheckoutAcceptance;
|
||||
$acceptance->checkoutable()->associate($consumable);
|
||||
$acceptance->assignedTo()->associate($user);
|
||||
$acceptance->qty = $quantity;
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
session([
|
||||
'sign_in_place_acceptance_id' => $acceptance->id,
|
||||
'sign_in_place_item_id' => $consumable->id,
|
||||
'sign_in_place_resource_type' => 'Consumables',
|
||||
]);
|
||||
|
||||
return redirect()->route('account.accept.item', $acceptance->id)
|
||||
->with('success', trans('admin/consumables/message.checkout.success'));
|
||||
}
|
||||
|
||||
// Redirect to the new consumable page
|
||||
return Helper::getRedirectOption($request, $consumable->id, 'Consumables')
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Licenses;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class BulkLicensesController extends Controller
|
||||
{
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$this->authorize('delete', License::class);
|
||||
|
||||
$errors = [];
|
||||
$success_count = 0;
|
||||
|
||||
foreach ($request->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'));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LicenseCheckoutRequest;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
@@ -101,17 +102,53 @@ class LicenseCheckoutController extends Controller
|
||||
session()->put(['checkout_to_type' => 'asset']);
|
||||
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
|
||||
$request->request->add(['assigned_asset' => $checkoutTarget->id]);
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'asset']);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => 'asset',
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
|
||||
} elseif ($request->filled('assigned_to')) {
|
||||
session()->put(['checkout_to_type' => 'user']);
|
||||
$checkoutTarget = $this->checkoutToUser($licenseSeat);
|
||||
$request->request->add(['assigned_user' => $checkoutTarget->id]);
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'user']);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => 'user',
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($checkoutTarget) {
|
||||
|
||||
// When sign_in_place is requested and the target is a user, redirect to the
|
||||
// acceptance/signature page so the user can sign in person.
|
||||
if ($request->boolean('sign_in_place') && $checkoutTarget instanceof User) {
|
||||
$acceptance = CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->where('checkoutable_id', $licenseSeat->id)
|
||||
->where('assigned_to_id', $checkoutTarget->id)
|
||||
->pending()
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// If requireAcceptance() is false the listener won't have created one; create it now.
|
||||
if (! $acceptance) {
|
||||
$acceptance = new CheckoutAcceptance;
|
||||
$acceptance->checkoutable()->associate($licenseSeat);
|
||||
$acceptance->assignedTo()->associate($checkoutTarget);
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
session([
|
||||
'sign_in_place_acceptance_id' => $acceptance->id,
|
||||
'sign_in_place_item_id' => $license->id,
|
||||
'sign_in_place_resource_type' => 'Licenses',
|
||||
]);
|
||||
|
||||
return redirect()->route('account.accept.item', $acceptance->id)
|
||||
->with('success', trans('admin/licenses/message.checkout.success'));
|
||||
}
|
||||
|
||||
return Helper::getRedirectOption($request, $license->id, 'Licenses')
|
||||
->with('success', trans('admin/licenses/message.checkout.success'));
|
||||
}
|
||||
@@ -150,7 +187,7 @@ class LicenseCheckoutController extends Controller
|
||||
$licenseSeat->assigned_to = $target->assigned_to;
|
||||
}
|
||||
if ($licenseSeat->save()) {
|
||||
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
|
||||
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
|
||||
|
||||
return $target;
|
||||
}
|
||||
@@ -167,7 +204,7 @@ class LicenseCheckoutController extends Controller
|
||||
$licenseSeat->assigned_to = request('assigned_to');
|
||||
|
||||
if ($licenseSeat->save()) {
|
||||
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
|
||||
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use League\Csv\EscapeFormula;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
@@ -388,6 +389,8 @@ class LicensesController extends Controller
|
||||
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
// Add a new row with data
|
||||
$values = [
|
||||
@@ -419,7 +422,14 @@ class LicensesController extends Controller
|
||||
$license->created_at,
|
||||
];
|
||||
|
||||
fputcsv($handle, $values);
|
||||
// CSV_ESCAPE_FORMULAS is set to false in the .env
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $values);
|
||||
|
||||
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($values));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\UploadFileRequest;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Maintenance;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* This controller handles all actions related to Asset Maintenance for
|
||||
@@ -72,6 +75,7 @@ class MaintenancesController extends Controller
|
||||
public function store(ImageUploadRequest $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', Asset::class);
|
||||
$this->validateUploadedFiles($request);
|
||||
|
||||
$assets = Asset::whereIn('id', $request->input('selected_assets'))->get();
|
||||
|
||||
@@ -102,12 +106,14 @@ class MaintenancesController extends Controller
|
||||
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
|
||||
}
|
||||
|
||||
$maintenance = $request->handleImages($maintenance);
|
||||
$request->handleImages($maintenance);
|
||||
|
||||
// Was the asset maintenance created?
|
||||
if (! $maintenance->save()) {
|
||||
return redirect()->back()->withInput()->withErrors($maintenance->getErrors());
|
||||
}
|
||||
|
||||
$this->storeUploadedFiles($request, $maintenance);
|
||||
}
|
||||
|
||||
return redirect()->route('maintenances.index')
|
||||
@@ -156,6 +162,7 @@ class MaintenancesController extends Controller
|
||||
{
|
||||
$this->authorize('update', Asset::class);
|
||||
$this->authorize('update', $maintenance->asset);
|
||||
$this->validateUploadedFiles($request);
|
||||
|
||||
$maintenance->supplier_id = $request->input('supplier_id');
|
||||
$maintenance->is_warranty = $request->input('is_warranty', 0);
|
||||
@@ -184,9 +191,11 @@ class MaintenancesController extends Controller
|
||||
$completionDate = Carbon::parse($maintenance->completion_date);
|
||||
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
|
||||
}
|
||||
$maintenance = $request->handleImages($maintenance);
|
||||
$request->handleImages($maintenance);
|
||||
|
||||
if ($maintenance->save()) {
|
||||
$this->storeUploadedFiles($request, $maintenance);
|
||||
|
||||
return redirect()->route('maintenances.index')
|
||||
->with('success', trans('admin/maintenances/message.edit.success'));
|
||||
}
|
||||
@@ -194,6 +203,56 @@ class MaintenancesController extends Controller
|
||||
return redirect()->back()->withInput()->withErrors($maintenance->getErrors());
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores any generic file uploads submitted from the maintenance form.
|
||||
*/
|
||||
private function storeUploadedFiles(ImageUploadRequest $request, Maintenance $maintenance): void
|
||||
{
|
||||
if (! $request->hasFile('file')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$objectType = 'maintenances';
|
||||
$storagePath = self::$map_storage_path[$objectType];
|
||||
|
||||
if (! Storage::exists($storagePath)) {
|
||||
Storage::makeDirectory($storagePath, 775);
|
||||
}
|
||||
|
||||
$uploadFileRequest = app(UploadFileRequest::class);
|
||||
|
||||
foreach ((array) $request->file('file') as $file) {
|
||||
if (! $file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = $uploadFileRequest->handleFile(
|
||||
$storagePath,
|
||||
self::$map_file_prefix[$objectType].'-'.$maintenance->id,
|
||||
$file
|
||||
);
|
||||
|
||||
$maintenance->logUpload($fileName, $request->input('file_notes'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate generic file uploads with the shared UploadFileRequest rules.
|
||||
*/
|
||||
private function validateUploadedFiles(ImageUploadRequest $request): void
|
||||
{
|
||||
if (! $request->hasFile('file')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uploadFileRequest = app(UploadFileRequest::class);
|
||||
|
||||
Validator::make(
|
||||
array_merge($request->all(), ['file' => $request->file('file')]),
|
||||
$uploadFileRequest->rules()
|
||||
)->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an asset maintenance
|
||||
*
|
||||
|
||||
@@ -211,14 +211,19 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function printInventory(): View
|
||||
{
|
||||
$show_users = User::where('id', auth()->user()->id)->get();
|
||||
$userId = auth()->id();
|
||||
|
||||
return view('users/print')
|
||||
->with('assets', auth()->user()->assets())
|
||||
->with('licenses', auth()->user()->licenses()->get())
|
||||
->with('accessories', auth()->user()->accessories()->get())
|
||||
->with('consumables', auth()->user()->consumables()->get())
|
||||
->with('users', $show_users)
|
||||
$show_user = User::withInventoryRelations($userId)->first();
|
||||
|
||||
$indirectItemsCount =
|
||||
$show_user->assets->flatMap->assignedAssets->count()
|
||||
+ $show_user->assets->flatMap->components->count()
|
||||
+ $show_user->assets->flatMap->licenses->count()
|
||||
+ $show_user->assets->flatMap->assignedAccessories->count();
|
||||
|
||||
return view('users.print')
|
||||
->with('users', [$show_user])
|
||||
->with('indirectItemsCount', $indirectItemsCount)
|
||||
->with('settings', Setting::getSettings());
|
||||
}
|
||||
|
||||
@@ -251,6 +256,7 @@ class ProfileController extends Controller
|
||||
|
||||
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
|
||||
{
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
$logentry = Actionlog::where('filename', $filename)->first();
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use League\Csv\EscapeFormula;
|
||||
@@ -55,6 +56,31 @@ class ReportsController extends Controller
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
$audit_alert_count = Asset::DueOrOverdueForAudit($settings)->count();
|
||||
$checkin_alert_count = Asset::DueOrOverdueForCheckin($settings)->count();
|
||||
// CheckoutAcceptance has no company_id column; scope through the checkoutable
|
||||
// relationship so each type's CompanyableTrait global scope is applied.
|
||||
$pending_acceptance_count = CheckoutAcceptance::pending()
|
||||
->whereHasMorph('checkoutable', [Asset::class, LicenseSeat::class, Accessory::class, Component::class, Consumable::class])
|
||||
->count();
|
||||
$licenses_low_count = License::withCount(['freeSeats as free_seats_count'])
|
||||
->get()
|
||||
->filter(fn ($l) => $l->free_seats_count <= 0)
|
||||
->count();
|
||||
|
||||
return view('reports/index', compact(
|
||||
'audit_alert_count',
|
||||
'checkin_alert_count',
|
||||
'pending_acceptance_count',
|
||||
'licenses_low_count',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a view that displays the accessories report.
|
||||
*
|
||||
@@ -251,6 +277,7 @@ class ReportsController extends Controller
|
||||
|
||||
$response = new StreamedResponse(function () {
|
||||
Log::debug('Starting streamed response');
|
||||
Log::debug('CSV escaping is set to: '.config('app.escape_formulas'));
|
||||
|
||||
// Open output stream
|
||||
$handle = fopen('php://output', 'w');
|
||||
@@ -286,6 +313,8 @@ class ReportsController extends Controller
|
||||
Log::debug('Walking results: '.$executionTime);
|
||||
$count = 0;
|
||||
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
foreach ($actionlogs as $actionlog) {
|
||||
$count++;
|
||||
$target_name = '';
|
||||
@@ -316,7 +345,15 @@ class ReportsController extends Controller
|
||||
$actionlog->action_source,
|
||||
$actionlog->log_meta,
|
||||
];
|
||||
fputcsv($handle, $row);
|
||||
|
||||
// CSV_ESCAPE_FORMULAS is set to false in the .env
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
|
||||
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -797,6 +834,14 @@ class ReportsController extends Controller
|
||||
$assets->onlyTrashed();
|
||||
}
|
||||
|
||||
if ($request->input('assignment_status') === 'assigned') {
|
||||
$assets->whereNotNull('assets.assigned_to');
|
||||
}
|
||||
|
||||
if ($request->input('assignment_status') === 'unassigned') {
|
||||
$assets->whereNull('assets.assigned_to');
|
||||
}
|
||||
|
||||
$assets->orderBy('assets.id', 'ASC')->chunk(500, function ($assets) use ($handle, $customfields, $request) {
|
||||
|
||||
$executionTime = microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'];
|
||||
@@ -843,7 +888,7 @@ class ReportsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('purchase_date')) {
|
||||
$row[] = ($asset->purchase_date) ? $asset->purchase_date : '';
|
||||
$row[] = ($asset->purchase_date) ? Carbon::parse($asset->purchase_date)->format('Y-m-d') : '';
|
||||
}
|
||||
|
||||
if ($request->filled('purchase_cost')) {
|
||||
@@ -851,7 +896,7 @@ class ReportsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('eol')) {
|
||||
$row[] = ($asset->asset_eol_date != '') ? $asset->asset_eol_date : '';
|
||||
$row[] = ($asset->asset_eol_date != '') ? Carbon::parse($asset->asset_eol_date)->format('Y-m-d') : '';
|
||||
}
|
||||
|
||||
if ($request->filled('warranty')) {
|
||||
@@ -917,7 +962,7 @@ class ReportsController extends Controller
|
||||
|
||||
if ($request->filled('user_company')) {
|
||||
if ($asset->checkedOutToUser()) {
|
||||
$row[] = ($asset->assignedto->company) ? $asset->assignedto->company->display_name : '';
|
||||
$row[] = ($asset->assignedto?->company) ? $asset->assignedto?->company?->display_name : '';
|
||||
} else {
|
||||
$row[] = ''; // Empty string if unassigned
|
||||
}
|
||||
@@ -1070,7 +1115,13 @@ class ReportsController extends Controller
|
||||
foreach ($customfields as $customfield) {
|
||||
$column_name = $customfield->db_column_name();
|
||||
if ($request->filled($customfield->db_column_name())) {
|
||||
$row[] = $asset->$column_name;
|
||||
$value = $asset->$column_name;
|
||||
|
||||
if (($customfield->field_encrypted == '1') && Gate::allows('assets.view.encrypted_custom_fields')) {
|
||||
$value = Helper::gracefulDecrypt($customfield, $value);
|
||||
}
|
||||
|
||||
$row[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\ActionType;
|
||||
use App\Helpers\Helper;
|
||||
use App\Helpers\StorageHelper;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
@@ -11,6 +12,7 @@ use App\Http\Requests\StoreLdapSettings;
|
||||
use App\Http\Requests\StoreLocalizationSettings;
|
||||
use App\Http\Requests\StoreNotificationSettings;
|
||||
use App\Http\Requests\StoreSecuritySettings;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CustomField;
|
||||
use App\Models\Group;
|
||||
@@ -870,6 +872,11 @@ class SettingsController extends Controller
|
||||
public function downloadFile($filename = null): RedirectResponse|BinaryFileResponse
|
||||
{
|
||||
$path = 'app/backups';
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
if ($this->hasInvalidBackupFilename($filename)) {
|
||||
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
|
||||
}
|
||||
|
||||
if (! config('app.lock_passwords')) {
|
||||
if (Storage::exists($path.'/'.$filename)) {
|
||||
@@ -895,6 +902,12 @@ class SettingsController extends Controller
|
||||
*/
|
||||
public function deleteFile($filename = null): RedirectResponse
|
||||
{
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
if ($this->hasInvalidBackupFilename($filename)) {
|
||||
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
|
||||
}
|
||||
|
||||
if (config('app.allow_backup_delete') == 'true') {
|
||||
|
||||
if (! config('app.lock_passwords')) {
|
||||
@@ -969,6 +982,11 @@ class SettingsController extends Controller
|
||||
*/
|
||||
public function postRestore(Request $request, $filename = null): RedirectResponse
|
||||
{
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
if ($this->hasInvalidBackupFilename($filename)) {
|
||||
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
|
||||
}
|
||||
|
||||
if (! config('app.lock_passwords')) {
|
||||
$path = 'app/backups';
|
||||
@@ -1118,7 +1136,86 @@ class SettingsController extends Controller
|
||||
*/
|
||||
public function api(): View
|
||||
{
|
||||
return view('settings.api');
|
||||
$personalAccessTokenCount = DB::table('oauth_access_tokens')
|
||||
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
|
||||
->where('oauth_clients.personal_access_client', true)
|
||||
->count();
|
||||
|
||||
return view('settings.api', [
|
||||
'personalAccessTokenCount' => $personalAccessTokenCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a personal access token from the admin OAuth settings page.
|
||||
*/
|
||||
public function revokePersonalAccessToken(string $token): RedirectResponse
|
||||
{
|
||||
$tokenRow = DB::table('oauth_access_tokens')
|
||||
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
|
||||
->where('oauth_access_tokens.id', $token)
|
||||
->where('oauth_clients.personal_access_client', true)
|
||||
->select(['oauth_access_tokens.id', 'oauth_access_tokens.user_id'])
|
||||
->first();
|
||||
|
||||
if ($tokenRow === null) {
|
||||
return redirect()
|
||||
->to(route('settings.oauth.index').'#personal-access-tokens')
|
||||
->with('error', trans('admin/settings/message.oauth.token_not_found'));
|
||||
}
|
||||
|
||||
DB::table('oauth_access_tokens')
|
||||
->where('id', $tokenRow->id)
|
||||
->update(['revoked' => true]);
|
||||
|
||||
$logaction = new Actionlog;
|
||||
$logaction->item_type = User::class;
|
||||
$logaction->item_id = $tokenRow->user_id;
|
||||
$logaction->target_type = User::class;
|
||||
$logaction->target_id = $tokenRow->user_id;
|
||||
$logaction->created_by = auth()->id();
|
||||
// $logaction->note = 'Token ID: ' . $tokenRow->id;
|
||||
$logaction->logaction(ActionType::TokenRevoked);
|
||||
|
||||
return redirect()
|
||||
->to(route('settings.oauth.index').'#personal-access-tokens')
|
||||
->with('success', trans('admin/settings/message.oauth.token_revoked'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unrevoke a personal access token from the admin OAuth settings page.
|
||||
*/
|
||||
public function unrevokePersonalAccessToken(string $token): RedirectResponse
|
||||
{
|
||||
$tokenRow = DB::table('oauth_access_tokens')
|
||||
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
|
||||
->where('oauth_access_tokens.id', $token)
|
||||
->where('oauth_clients.personal_access_client', true)
|
||||
->select(['oauth_access_tokens.id', 'oauth_access_tokens.user_id'])
|
||||
->first();
|
||||
|
||||
if ($tokenRow === null) {
|
||||
return redirect()
|
||||
->to(route('settings.oauth.index').'#personal-access-tokens')
|
||||
->with('error', trans('admin/settings/message.oauth.token_not_found'));
|
||||
}
|
||||
|
||||
DB::table('oauth_access_tokens')
|
||||
->where('id', $tokenRow->id)
|
||||
->update(['revoked' => false]);
|
||||
|
||||
$logaction = new Actionlog;
|
||||
$logaction->item_type = User::class;
|
||||
$logaction->item_id = $tokenRow->user_id;
|
||||
$logaction->target_type = User::class;
|
||||
$logaction->target_id = $tokenRow->user_id;
|
||||
$logaction->created_by = auth()->id();
|
||||
// $logaction->note = 'Token ID: ' . $tokenRow->id;
|
||||
$logaction->logaction(ActionType::TokenUnrevoked);
|
||||
|
||||
return redirect()
|
||||
->to(route('settings.oauth.index').'#personal-access-tokens')
|
||||
->with('success', trans('admin/settings/message.oauth.token_unrevoked'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1155,4 +1252,62 @@ class SettingsController extends Controller
|
||||
{
|
||||
return view('settings.logins');
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an OAuth client from the admin OAuth settings page.
|
||||
*/
|
||||
public function revokeOAuthClient(string $client): RedirectResponse
|
||||
{
|
||||
$oauthClient = DB::table('oauth_clients')
|
||||
->where('id', $client)
|
||||
->first();
|
||||
|
||||
if ($oauthClient === null) {
|
||||
return redirect()
|
||||
->to(route('settings.oauth.index').'#oauth-clients')
|
||||
->with('error', trans('admin/settings/message.oauth.client_not_found'));
|
||||
}
|
||||
|
||||
DB::table('oauth_clients')
|
||||
->where('id', $client)
|
||||
->update(['revoked' => true]);
|
||||
|
||||
return redirect()
|
||||
->to(route('settings.oauth.index').'#oauth-clients')
|
||||
->with('success', trans('admin/settings/message.oauth.client_revoked'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unrevoke an OAuth client from the admin OAuth settings page.
|
||||
*/
|
||||
public function unrevokeOAuthClient(string $client): RedirectResponse
|
||||
{
|
||||
$oauthClient = DB::table('oauth_clients')
|
||||
->where('id', $client)
|
||||
->first();
|
||||
|
||||
if ($oauthClient === null) {
|
||||
return redirect()
|
||||
->to(route('settings.oauth.index').'#oauth-clients')
|
||||
->with('error', trans('admin/settings/message.oauth.client_not_found'));
|
||||
}
|
||||
|
||||
DB::table('oauth_clients')
|
||||
->where('id', $client)
|
||||
->update(['revoked' => false]);
|
||||
|
||||
return redirect()
|
||||
->to(route('settings.oauth.index').'#oauth-clients')
|
||||
->with('success', trans('admin/settings/message.oauth.client_unrevoked'));
|
||||
}
|
||||
|
||||
private function hasInvalidBackupFilename(string $filename): bool
|
||||
{
|
||||
if ($filename === '' || $filename === '.' || $filename === '..') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reject path separators in case a crafted value survives route decoding.
|
||||
return str_contains($filename, '/') || str_contains($filename, '\\');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,17 @@ class StorageProxyController extends Controller
|
||||
*/
|
||||
public function show(string $path): Response|StreamedResponse
|
||||
{
|
||||
if ($this->hasPathTraversalSegments($path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
// The S3 adapter includes the disk's root prefix in generated URLs,
|
||||
// but Flysystem also prepends it internally on every operation.
|
||||
// Strip it here to avoid double-prefixing.
|
||||
$root = trim(config('filesystems.disks.public.root', ''), '/');
|
||||
if ($root !== '' && str_starts_with($path, $root . '/')) {
|
||||
if ($root !== '' && str_starts_with($path, $root.'/')) {
|
||||
$path = substr($path, strlen($root) + 1);
|
||||
}
|
||||
|
||||
@@ -33,12 +37,12 @@ class StorageProxyController extends Controller
|
||||
|
||||
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
|
||||
$lastModified = $disk->lastModified($path);
|
||||
$etag = md5($path . $lastModified);
|
||||
$etag = md5($path.$lastModified);
|
||||
$size = $disk->size($path);
|
||||
|
||||
if ($this->isNotModified($etag, $lastModified)) {
|
||||
return response('', 304)
|
||||
->header('ETag', '"' . $etag . '"')
|
||||
->header('ETag', '"'.$etag.'"')
|
||||
->header('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
|
||||
@@ -51,8 +55,8 @@ class StorageProxyController extends Controller
|
||||
}, 200, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Length' => $size,
|
||||
'ETag' => '"' . $etag . '"',
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
|
||||
'ETag' => '"'.$etag.'"',
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified).' GMT',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
@@ -60,7 +64,7 @@ class StorageProxyController extends Controller
|
||||
private function isNotModified(string $etag, int $lastModified): bool
|
||||
{
|
||||
$requestEtag = request()->header('If-None-Match');
|
||||
if ($requestEtag && $requestEtag === '"' . $etag . '"') {
|
||||
if ($requestEtag && $requestEtag === '"'.$etag.'"') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -71,4 +75,16 @@ class StorageProxyController extends Controller
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasPathTraversalSegments(string $path): bool
|
||||
{
|
||||
$normalizedPath = str_replace('\\', '/', $path);
|
||||
|
||||
return str_contains($normalizedPath, "\0")
|
||||
|| str_starts_with($normalizedPath, '/')
|
||||
|| str_contains($normalizedPath, '../')
|
||||
|| str_contains($normalizedPath, '/..')
|
||||
|| str_ends_with($normalizedPath, '/..')
|
||||
|| $normalizedPath === '..';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +362,9 @@ class BulkUsersController extends Controller
|
||||
$logAction->target_id = $item->assigned_to;
|
||||
$logAction->target_type = User::class;
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->note = 'Bulk checkin items';
|
||||
$logAction->action_date = now();
|
||||
$logAction->created_at = now();
|
||||
$logAction->note = 'Bulk checkin items on user bulk edit/delete';
|
||||
$logAction->logaction('checkin from');
|
||||
}
|
||||
}
|
||||
@@ -376,7 +378,9 @@ class BulkUsersController extends Controller
|
||||
$logAction->target_id = $accessoryUserRow->assigned_to;
|
||||
$logAction->target_type = User::class;
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->note = 'Bulk checkin items';
|
||||
$logAction->created_at = now();
|
||||
$logAction->action_date = now();
|
||||
$logAction->note = 'Bulk checkin accessory on user bulk edit/delete';
|
||||
$logAction->logaction('checkin from');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DeleteUserRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\SaveUserRequest;
|
||||
use App\Mail\UnacceptedAssetReminderMail;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Group;
|
||||
use App\Models\Setting;
|
||||
@@ -22,7 +24,9 @@ use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use League\Csv\EscapeFormula;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
@@ -436,6 +440,7 @@ class UsersController extends Controller
|
||||
'accessories',
|
||||
'licenses',
|
||||
'userloc',
|
||||
'groups',
|
||||
])
|
||||
->withTrashed()
|
||||
->find($user->id);
|
||||
@@ -446,6 +451,7 @@ class UsersController extends Controller
|
||||
return view('users/view', [
|
||||
'user' => $user,
|
||||
'settings' => Setting::getSettings(),
|
||||
'effectivePermissionsBySection' => $user->getEffectivePermissionsBySection(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -567,6 +573,8 @@ class UsersController extends Controller
|
||||
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user_groups = '';
|
||||
|
||||
@@ -609,7 +617,14 @@ class UsersController extends Controller
|
||||
$user->created_at,
|
||||
];
|
||||
|
||||
fputcsv($handle, $values);
|
||||
// CSV_ESCAPE_FORMULAS is set to false in the .env
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $values);
|
||||
|
||||
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($values));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -634,32 +649,16 @@ class UsersController extends Controller
|
||||
{
|
||||
$this->authorize('view', User::class);
|
||||
|
||||
$user = User::where('id', $id)
|
||||
->with([
|
||||
'assets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
|
||||
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
|
||||
'assets.assignedAssets.defaultLoc',
|
||||
'assets.assignedAssets.location',
|
||||
'assets.assignedAssets.model.category',
|
||||
'assets.defaultLoc',
|
||||
'assets.location',
|
||||
'assets.model.category',
|
||||
'accessories.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
|
||||
'accessories.category',
|
||||
'accessories.manufacturer',
|
||||
'consumables.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
|
||||
'consumables.category',
|
||||
'consumables.manufacturer',
|
||||
'licenses.category',
|
||||
])
|
||||
->withTrashed()
|
||||
->first();
|
||||
$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();
|
||||
|
||||
if ($user) {
|
||||
$this->authorize('view', $user);
|
||||
|
||||
return view('users.print')
|
||||
->with('users', [$user])
|
||||
->with('indirectItemsCount', $indirectItemsCount)
|
||||
->with('settings', Setting::getSettings());
|
||||
}
|
||||
|
||||
@@ -700,6 +699,48 @@ class UsersController extends Controller
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend pending acceptance reminder email for a specific user.
|
||||
*/
|
||||
public function resendAcceptanceReminder(User $user): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $user);
|
||||
|
||||
if (empty($user->email)) {
|
||||
return redirect()->back()->with('error', trans('admin/users/message.user_has_no_email'));
|
||||
}
|
||||
|
||||
if ($user->activated == '0') {
|
||||
return redirect()->back()->with('error', trans('admin/users/message.not_activated'));
|
||||
}
|
||||
|
||||
$pendingItems = $user->getAssignedItemsWithPendingAcceptance();
|
||||
|
||||
if ($pendingItems->isEmpty()) {
|
||||
return redirect()->back()->with('warning', trans('admin/users/message.error.no_pending_acceptances'));
|
||||
}
|
||||
|
||||
$firstAcceptance = CheckoutAcceptance::query()
|
||||
->forUser($user)
|
||||
->pending()
|
||||
->with('assignedTo')
|
||||
->first();
|
||||
|
||||
if (! $firstAcceptance) {
|
||||
return redirect()->back()->with('warning', trans('admin/users/message.error.no_pending_acceptances'));
|
||||
}
|
||||
|
||||
$mailable = new UnacceptedAssetReminderMail($firstAcceptance, $pendingItems->count());
|
||||
|
||||
if (! empty($user->locale)) {
|
||||
$mailable->locale($user->locale);
|
||||
}
|
||||
|
||||
Mail::to($user->email)->send($mailable);
|
||||
|
||||
return redirect()->back()->with('success', trans_choice('admin/users/message.success.acceptance_reminder_sent', $pendingItems->count(), ['count' => $pendingItems->count()]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send individual password reset email
|
||||
*
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Http\Middleware\CheckLocale;
|
||||
use App\Http\Middleware\CheckPermissions;
|
||||
use App\Http\Middleware\CheckUserIsActivated;
|
||||
use App\Http\Middleware\EncryptCookies;
|
||||
use App\Http\Middleware\LogAuthedUserHeader;
|
||||
use App\Http\Middleware\NoSessionStore;
|
||||
use App\Http\Middleware\PreventBackHistory;
|
||||
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||
@@ -82,6 +83,7 @@ class Kernel extends HttpKernel
|
||||
'api' => [
|
||||
'auth:api',
|
||||
CheckLocale::class,
|
||||
LogAuthedUserHeader::class,
|
||||
SubstituteBindings::class,
|
||||
],
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class CheckForTwoFactor
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
redirect()->setIntendedUrl(url()->full()); // save the 'current' URL so we can send the user back to it?
|
||||
// Otherwise make sure they're enrolled and show them the 2FA code screen
|
||||
if ((auth()->user()->two_factor_secret != '') && (auth()->user()->two_factor_enrolled == '1')) {
|
||||
return redirect()->route('two-factor')->with('info', trans('auth/message.two_factor.enter_two_factor_code'));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LogAuthedUserHeader
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
if ((config('app.authorized_user_header') === true) && ($request->bearerToken() != '')) {
|
||||
$response->headers->set('X-API-User-ID', auth()?->id());
|
||||
$response->headers->set('X-API-Token-Name', $request->user()?->token()?->name);
|
||||
$response->headers->set('X-API-Token-ID', $request->user()?->token()?->id);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@ class AssetCheckinRequest extends Request
|
||||
{
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
$rules = [];
|
||||
$rules = [
|
||||
'set_requestable' => 'nullable|boolean',
|
||||
];
|
||||
|
||||
if ($settings->require_checkinout_notes) {
|
||||
$rules['note'] = 'string|required';
|
||||
|
||||
@@ -39,6 +39,8 @@ class AssetCheckoutRequest extends Request
|
||||
'nullable',
|
||||
'date',
|
||||
],
|
||||
'requestable' => 'nullable|boolean',
|
||||
'set_not_requestable' => 'nullable|boolean',
|
||||
];
|
||||
|
||||
if ($settings->require_checkinout_notes) {
|
||||
|
||||
@@ -29,6 +29,7 @@ class CustomAssetReportRequest extends Request
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'assignment_status' => 'nullable|in:all,assigned,unassigned',
|
||||
'purchase_start' => 'date|date_format:Y-m-d|nullable',
|
||||
'purchase_end' => 'date|date_format:Y-m-d|nullable',
|
||||
'purchase_cost_end' => 'numeric|nullable|gte:purchase_cost_start',
|
||||
|
||||
@@ -57,6 +57,7 @@ class ImageUploadRequest extends Request
|
||||
* had it once to allow encoded image uploads.
|
||||
*/
|
||||
return [
|
||||
'avatar' => 'auto',
|
||||
'image' => 'auto',
|
||||
'image_source' => 'auto',
|
||||
];
|
||||
|
||||
@@ -192,8 +192,8 @@ class AssetsTransformer
|
||||
'pivot_id' => $component->pivot->id,
|
||||
'name' => e($component->name),
|
||||
'qty' => $component->pivot->assigned_qty,
|
||||
'price_cost' => $component->purchase_cost,
|
||||
'purchase_total' => $component->purchase_cost * $component->pivot->assigned_qty,
|
||||
'purchase_cost' => $component->purchase_cost,
|
||||
'purchase_total' => $component->calculated_purchase_cost,
|
||||
'checkout_date' => Helper::getFormattedDateObject($component->pivot->created_at, 'datetime'),
|
||||
|
||||
];
|
||||
@@ -403,9 +403,10 @@ class AssetsTransformer
|
||||
$array[] = [
|
||||
'assigned_pivot_id' => $component_checkout->id,
|
||||
'name' => [
|
||||
'id' => $component_checkout->component->id,
|
||||
'name' => e($component_checkout->component->display_name),
|
||||
'id' => $component_checkout->component?->id,
|
||||
'name' => e($component_checkout->component?->display_name),
|
||||
'type' => 'component',
|
||||
'deleted_at' => $component_checkout->component?->deleted_at,
|
||||
],
|
||||
'assigned_qty' => $component_checkout->assigned_qty,
|
||||
'note' => ($component_checkout->note) ? e($component_checkout->note) : null,
|
||||
@@ -414,7 +415,10 @@ class AssetsTransformer
|
||||
'id' => (int) $component_checkout->adminuser->id,
|
||||
'name' => e($component_checkout->adminuser->display_name),
|
||||
] : null,
|
||||
'available_actions' => ['checkin' => Gate::allows('checkin', Component::class)],
|
||||
'available_actions' => [
|
||||
'checkin' => (($component_checkout->component?->deleted_at == '') && Gate::allows('checkin', Component::class)),
|
||||
'view' => (($component_checkout->component?->deleted_at == '') && Gate::allows('view', Component::class)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class CategoriesTransformer
|
||||
'name' => e($category->name),
|
||||
'image' => ($category->image) ? Storage::disk('public')->url('categories/'.e($category->image)) : null,
|
||||
'category_type' => Helper::categoryTypeList($category->category_type),
|
||||
'has_eula' => ($category->getEula() ? true : false),
|
||||
'has_eula' => ($category->eula_text) ? true : false,
|
||||
'use_default_eula' => ($category->use_default_eula == '1' ? true : false),
|
||||
'eula' => ($category->getEula()),
|
||||
'checkin_email' => ($category->checkin_email == '1'),
|
||||
|
||||
@@ -75,7 +75,10 @@ class LicensesTransformer
|
||||
'checkin' => Gate::allows('checkin', License::class),
|
||||
'clone' => Gate::allows('create', License::class),
|
||||
'update' => Gate::allows('update', License::class),
|
||||
'delete' => (Gate::allows('delete', License::class) && ($license->free_seats_count == $license->seats)) ? true : false,
|
||||
'delete' => $license->isDeletable(),
|
||||
'bulk_selectable' => [
|
||||
'delete' => $license->isDeletable(),
|
||||
],
|
||||
];
|
||||
|
||||
$array += $permissions_array;
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Helpers\Helper;
|
||||
use App\Helpers\StorageHelper;
|
||||
use App\Models\Actionlog;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -24,6 +25,17 @@ class UploadedFilesTransformer
|
||||
public function transformFile(Actionlog $file)
|
||||
{
|
||||
$snipeModel = $file->item_type;
|
||||
$item = null;
|
||||
|
||||
if (is_string($snipeModel) && class_exists($snipeModel)) {
|
||||
$itemQuery = $snipeModel::query();
|
||||
|
||||
if (in_array(SoftDeletes::class, class_uses_recursive($snipeModel), true)) {
|
||||
$itemQuery->withTrashed();
|
||||
}
|
||||
|
||||
$item = $itemQuery->find($file->item_id);
|
||||
}
|
||||
|
||||
$array = [
|
||||
'id' => (int) $file->id,
|
||||
@@ -49,7 +61,7 @@ class UploadedFilesTransformer
|
||||
];
|
||||
|
||||
$permissions_array['available_actions'] = [
|
||||
'delete' => (Gate::allows('update', $snipeModel) && ($file->deleted_at == '')),
|
||||
'delete' => (Gate::allows('update', $item ?? $snipeModel) && ($file->deleted_at == '')),
|
||||
];
|
||||
|
||||
$array += $permissions_array;
|
||||
|
||||
@@ -34,12 +34,14 @@ use App\Notifications\CheckoutLicenseSeatNotification;
|
||||
use Exception;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Notifications\Notification as BaseNotification;
|
||||
use Illuminate\Support\Facades\Context;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Osama\LaravelTeamsNotification\TeamsNotification;
|
||||
|
||||
class CheckoutableListener
|
||||
{
|
||||
private array $skipNotificationsFor = [
|
||||
@@ -80,6 +82,11 @@ class CheckoutableListener
|
||||
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance);
|
||||
$shouldSendWebhookNotification = $this->shouldSendWebhookNotification();
|
||||
|
||||
if ($this->shouldSkipInitialAcceptanceEmail($event, $acceptance)) {
|
||||
$shouldSendEmailToUser = false;
|
||||
$shouldSendEmailToAlertAddress = false;
|
||||
}
|
||||
|
||||
if (! $shouldSendEmailToUser && ! $shouldSendEmailToAlertAddress && ! $shouldSendWebhookNotification) {
|
||||
return;
|
||||
}
|
||||
@@ -120,12 +127,12 @@ class CheckoutableListener
|
||||
if ($shouldSendWebhookNotification) {
|
||||
try {
|
||||
if ($this->newMicrosoftTeamsWebhookEnabled()) {
|
||||
$message = $this->getCheckoutNotification($event)->toMicrosoftTeams();
|
||||
$message = $this->getCheckoutNotification($event, $acceptance, true)->toMicrosoftTeams();
|
||||
$notification = new TeamsNotification(Setting::getSettings()->webhook_endpoint);
|
||||
$notification->success()->sendMessage($message[0], $message[1]); // Send the message to Microsoft Teams
|
||||
} else {
|
||||
Notification::route($this->webhookSelected(), Setting::getSettings()->webhook_endpoint)
|
||||
->notify($this->getCheckoutNotification($event, $acceptance));
|
||||
->notify($this->getCheckoutNotification($event, $acceptance, true));
|
||||
}
|
||||
} catch (ClientException $e) {
|
||||
$status = $e->getResponse()->getStatusCode();
|
||||
@@ -227,12 +234,12 @@ class CheckoutableListener
|
||||
// Send Webhook notification
|
||||
try {
|
||||
if ($this->newMicrosoftTeamsWebhookEnabled()) {
|
||||
$message = $this->getCheckinNotification($event)->toMicrosoftTeams();
|
||||
$message = $this->getCheckinNotification($event, true)->toMicrosoftTeams();
|
||||
$notification = new TeamsNotification(Setting::getSettings()->webhook_endpoint);
|
||||
$notification->success()->sendMessage($message[0], $message[1]); // Send the message to Microsoft Teams
|
||||
} else {
|
||||
Notification::route($this->webhookSelected(), Setting::getSettings()->webhook_endpoint)
|
||||
->notify($this->getCheckinNotification($event));
|
||||
->notify($this->getCheckinNotification($event, true));
|
||||
}
|
||||
} catch (ClientException $e) {
|
||||
$status = $e->getResponse()->getStatusCode();
|
||||
@@ -306,12 +313,12 @@ class CheckoutableListener
|
||||
* @param CheckoutableCheckedIn $event
|
||||
* @return Notification
|
||||
*/
|
||||
private function getCheckinNotification($event)
|
||||
private function getCheckinNotification($event, bool $refreshCheckoutable = false): BaseNotification
|
||||
{
|
||||
|
||||
$notificationClass = null;
|
||||
$checkoutable = $this->getCheckoutableForNotification($event->checkoutable, $refreshCheckoutable);
|
||||
|
||||
switch (get_class($event->checkoutable)) {
|
||||
switch (get_class($checkoutable)) {
|
||||
case Accessory::class:
|
||||
$notificationClass = CheckinAccessoryNotification::class;
|
||||
break;
|
||||
@@ -328,7 +335,7 @@ class CheckoutableListener
|
||||
|
||||
Log::debug('Notification class: '.$notificationClass);
|
||||
|
||||
return new $notificationClass($event->checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note);
|
||||
return new $notificationClass($checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,11 +345,12 @@ class CheckoutableListener
|
||||
* @param CheckoutAcceptance|null $acceptance
|
||||
* @return Notification
|
||||
*/
|
||||
private function getCheckoutNotification($event, $acceptance = null)
|
||||
private function getCheckoutNotification($event, $acceptance = null, bool $refreshCheckoutable = false): BaseNotification
|
||||
{
|
||||
$notificationClass = null;
|
||||
$checkoutable = $this->getCheckoutableForNotification($event->checkoutable, $refreshCheckoutable);
|
||||
|
||||
switch (get_class($event->checkoutable)) {
|
||||
switch (get_class($checkoutable)) {
|
||||
case Accessory::class:
|
||||
$notificationClass = CheckoutAccessoryNotification::class;
|
||||
break;
|
||||
@@ -360,7 +368,16 @@ class CheckoutableListener
|
||||
break;
|
||||
}
|
||||
|
||||
return new $notificationClass($event->checkoutable, $event->checkedOutTo, $event->checkedOutBy, $acceptance, $event->note);
|
||||
return new $notificationClass($checkoutable, $event->checkedOutTo, $event->checkedOutBy, $acceptance, $event->note);
|
||||
}
|
||||
|
||||
private function getCheckoutableForNotification(Model $checkoutable, bool $shouldRefresh): Model
|
||||
{
|
||||
if (! $shouldRefresh) {
|
||||
return $checkoutable;
|
||||
}
|
||||
|
||||
return $checkoutable->fresh() ?? $checkoutable;
|
||||
}
|
||||
|
||||
private function getCheckoutMailType($event, $acceptance)
|
||||
@@ -480,6 +497,15 @@ class CheckoutableListener
|
||||
return false;
|
||||
}
|
||||
|
||||
private function shouldSkipInitialAcceptanceEmail(CheckoutableCheckedOut $event, ?CheckoutAcceptance $acceptance): bool
|
||||
{
|
||||
if (! $event->signInPlace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($acceptance instanceof CheckoutAcceptance) || ! empty($event->checkoutable->getEula());
|
||||
}
|
||||
|
||||
private function shouldSendEmailToAlertAddress($acceptance = null): bool
|
||||
{
|
||||
if (Context::get('action') === 'bulk_asset_checkout') {
|
||||
|
||||
@@ -135,14 +135,18 @@ class CheckoutablesCheckedOutInBulkListener
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getNotifiableUser(CheckoutablesCheckedOutInBulk $event): ?Model
|
||||
private function getNotifiableUser(CheckoutablesCheckedOutInBulk $event): ?User
|
||||
{
|
||||
$target = $event->target;
|
||||
|
||||
if ($target instanceof Asset) {
|
||||
$target->load('assignedTo');
|
||||
|
||||
return $target->assignedto;
|
||||
if ($target->assigned instanceof User) {
|
||||
return $target->assigned;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($target instanceof Location) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Livewire component for the admin-facing User API Tokens (Personal Access Tokens) table.
|
||||
* Displays all personal access tokens across all users, used on the Settings > OAuth page.
|
||||
*/
|
||||
class AdminPersonalAccessTokens extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
$tokens = DB::table('oauth_access_tokens')
|
||||
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
|
||||
->leftJoin('users', 'oauth_access_tokens.user_id', '=', 'users.id')
|
||||
->where('oauth_clients.personal_access_client', true)
|
||||
->select([
|
||||
'oauth_access_tokens.id',
|
||||
'oauth_access_tokens.name',
|
||||
'oauth_access_tokens.revoked',
|
||||
'oauth_access_tokens.created_at',
|
||||
'oauth_access_tokens.expires_at',
|
||||
'oauth_access_tokens.user_id as token_user_id',
|
||||
'oauth_clients.name as client_name',
|
||||
'users.id as existing_user_id',
|
||||
'users.username as username',
|
||||
'users.display_name as display_name',
|
||||
'users.deleted_at as user_deleted_at',
|
||||
])
|
||||
->orderByDesc('oauth_access_tokens.created_at')
|
||||
->get();
|
||||
|
||||
return view('livewire.admin-personal-access-tokens', [
|
||||
'tokens' => $tokens,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -45,4 +46,10 @@ class CategoryEditForm extends Component
|
||||
{
|
||||
return (bool) $this->useDefaultEula;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isGlobalSignatureRequired(): bool
|
||||
{
|
||||
return (string) Setting::getSettings()->require_accept_signature === '1';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,6 +639,10 @@ class Importer extends Component
|
||||
'color code',
|
||||
trans('general.tag_color'),
|
||||
],
|
||||
'checkout_class' => [
|
||||
'checkout type',
|
||||
'checkout class',
|
||||
],
|
||||
];
|
||||
|
||||
$this->columnOptions[''] = $this->getColumns(''); // blank mode? I don't know what this is supposed to mean
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Passport\Client;
|
||||
use Laravel\Passport\ClientRepository;
|
||||
use Laravel\Passport\TokenRepository;
|
||||
use Livewire\Component;
|
||||
|
||||
class OauthClients extends Component
|
||||
{
|
||||
public string $section = 'all';
|
||||
|
||||
public $name;
|
||||
|
||||
public $redirect;
|
||||
@@ -22,11 +24,77 @@ class OauthClients extends Component
|
||||
|
||||
public $authorizationError;
|
||||
|
||||
public function mount(?string $section = null): void
|
||||
{
|
||||
if ($section !== null) {
|
||||
$this->section = $section;
|
||||
}
|
||||
}
|
||||
|
||||
public function showOauthClients(): bool
|
||||
{
|
||||
return in_array($this->section, ['all', 'oauth-clients'], true);
|
||||
}
|
||||
|
||||
public function showAuthorizedApplications(): bool
|
||||
{
|
||||
return in_array($this->section, ['all', 'authorized-applications'], true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$clients = collect();
|
||||
if ($this->showOauthClients()) {
|
||||
$clients = Client::query()
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
if ($clients->isNotEmpty()) {
|
||||
$tokenCountsByClientId = DB::table('oauth_access_tokens')
|
||||
->whereIn('client_id', $clients->pluck('id')->all())
|
||||
->selectRaw('client_id, COUNT(*) as token_count')
|
||||
->groupBy('client_id')
|
||||
->pluck('token_count', 'client_id');
|
||||
|
||||
$clients->each(function ($client) use ($tokenCountsByClientId): void {
|
||||
$client->setAttribute('associated_token_count', (int) ($tokenCountsByClientId[$client->id] ?? 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$authorizedApplications = collect();
|
||||
if ($this->showAuthorizedApplications()) {
|
||||
$authorizedTokenSummary = DB::table('oauth_access_tokens as tokens')
|
||||
->where('tokens.revoked', false)
|
||||
->selectRaw('tokens.client_id')
|
||||
->selectRaw('MAX(tokens.scopes) as scopes')
|
||||
->selectRaw('MAX(tokens.created_at) as created_at')
|
||||
->selectRaw('MAX(tokens.expires_at) as expires_at')
|
||||
->groupBy('tokens.client_id');
|
||||
|
||||
$authorizedApplications = DB::table('oauth_clients as clients')
|
||||
->joinSub($authorizedTokenSummary, 'token_summary', function ($join) {
|
||||
$join->on('clients.id', '=', 'token_summary.client_id');
|
||||
})
|
||||
->leftJoin('users as creators', 'clients.user_id', '=', 'creators.id')
|
||||
->select([
|
||||
'clients.id as client_id',
|
||||
'clients.name as client_name',
|
||||
'clients.user_id as client_owner_id',
|
||||
'creators.display_name as client_owner_display_name',
|
||||
'creators.username as client_owner_username',
|
||||
'creators.deleted_at as client_owner_deleted_at',
|
||||
'token_summary.scopes',
|
||||
'token_summary.created_at',
|
||||
'token_summary.expires_at',
|
||||
])
|
||||
->orderByDesc('token_summary.created_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('livewire.oauth-clients', [
|
||||
'clients' => app(ClientRepository::class)->activeForUser(auth()->id()),
|
||||
'authorized_tokens' => app(TokenRepository::class)->forUser(auth()->id())->where('revoked', false),
|
||||
'clients' => $clients,
|
||||
'authorizedApplications' => $authorizedApplications,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -43,6 +111,7 @@ class OauthClients extends Component
|
||||
$this->redirect,
|
||||
);
|
||||
|
||||
session()->flash('success', trans('admin/settings/message.oauth.client_created'));
|
||||
$this->dispatch('clientCreated');
|
||||
}
|
||||
|
||||
@@ -50,22 +119,27 @@ class OauthClients extends Component
|
||||
{
|
||||
// test for safety
|
||||
// ->delete must be of type Client - thus the model binding
|
||||
if ($clientId->user_id == auth()->id()) {
|
||||
if ((auth()->user()?->isSuperUser()) || ($clientId->user_id == auth()->id())) {
|
||||
app(ClientRepository::class)->delete($clientId);
|
||||
session()->flash('success', trans('admin/settings/message.oauth.client_deleted'));
|
||||
} else {
|
||||
Log::warning('User '.auth()->id().' attempted to delete client '.$clientId->id.' which belongs to user '.$clientId->created_by);
|
||||
$this->authorizationError = 'You are not authorized to delete this client.';
|
||||
$this->authorizationError = trans('admin/settings/message.oauth.client_delete_denied');
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteToken($tokenId): void
|
||||
public function deleteAuthorizedApplication(int $clientId): void
|
||||
{
|
||||
$token = app(TokenRepository::class)->find($tokenId);
|
||||
if ($token->created_by == auth()->id()) {
|
||||
app(TokenRepository::class)->revokeAccessToken($tokenId);
|
||||
$revokedTokenCount = DB::table('oauth_access_tokens')
|
||||
->where('client_id', $clientId)
|
||||
->where('revoked', false)
|
||||
->update(['revoked' => true]);
|
||||
|
||||
if ($revokedTokenCount > 0) {
|
||||
session()->flash('success', trans('admin/settings/message.oauth.token_deleted'));
|
||||
} else {
|
||||
Log::warning('User '.auth()->id().' attempted to delete token '.$tokenId.' which belongs to user '.$token->created_by);
|
||||
$this->authorizationError = 'You are not authorized to delete this token.';
|
||||
Log::warning('User '.auth()->id().' attempted to revoke authorized application client '.$clientId.' without matching active tokens.');
|
||||
$this->authorizationError = trans('admin/settings/message.oauth.token_delete_denied');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +165,10 @@ class OauthClients extends Component
|
||||
$client->name = $this->editName;
|
||||
$client->redirect = $this->editRedirect;
|
||||
$client->save();
|
||||
session()->flash('success', trans('admin/settings/message.oauth.client_updated'));
|
||||
} else {
|
||||
Log::warning('User '.auth()->id().' attempted to edit client '.$editClientId->id.' which belongs to user '.$client->created_by);
|
||||
$this->authorizationError = 'You are not authorized to edit this client.';
|
||||
$this->authorizationError = trans('admin/settings/message.oauth.client_edit_denied');
|
||||
}
|
||||
|
||||
$this->dispatch('clientUpdated');
|
||||
|
||||
@@ -58,10 +58,26 @@ class CheckinAssetMail extends BaseMailable
|
||||
{
|
||||
$this->item->load('status');
|
||||
$fields = [];
|
||||
$customFields = [];
|
||||
|
||||
// Check if the item has custom fields associated with it
|
||||
if (($this->item->model) && ($this->item->model->fieldset)) {
|
||||
$fields = $this->item->model->fieldset->fields;
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (! $field->show_in_email || $field->field_encrypted == '1') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->item->{$field->db_column_name()};
|
||||
|
||||
if (! is_null($value) && $value !== '') {
|
||||
$customFields[] = [
|
||||
'label' => $field->name,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Content(
|
||||
@@ -73,6 +89,7 @@ class CheckinAssetMail extends BaseMailable
|
||||
'note' => $this->note,
|
||||
'target' => $this->target,
|
||||
'fields' => $fields,
|
||||
'custom_fields' => $customFields,
|
||||
'expected_checkin' => $this->expected_checkin,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -75,6 +75,7 @@ class CheckoutAssetMail extends BaseMailable
|
||||
$eula = method_exists($this->item, 'getEula') ? $this->item->getEula() : '';
|
||||
$req_accept = $this->requiresAcceptance();
|
||||
$fields = [];
|
||||
$customFields = [];
|
||||
$name = null;
|
||||
|
||||
if ($this->target instanceof User) {
|
||||
@@ -88,6 +89,21 @@ class CheckoutAssetMail extends BaseMailable
|
||||
// Check if the item has custom fields associated with it
|
||||
if (($this->item->model) && ($this->item->model->fieldset)) {
|
||||
$fields = $this->item->model->fieldset->fields;
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (! $field->show_in_email || $field->field_encrypted == '1') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->item->{$field->db_column_name()};
|
||||
|
||||
if (! is_null($value) && $value !== '') {
|
||||
$customFields[] = [
|
||||
'label' => $field->name,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$accept_url = is_null($this->acceptance) ? null : route('account.accept.item', $this->acceptance);
|
||||
@@ -101,6 +117,7 @@ class CheckoutAssetMail extends BaseMailable
|
||||
'note' => $this->note,
|
||||
'target' => $name,
|
||||
'fields' => $fields,
|
||||
'custom_fields' => $customFields,
|
||||
'eula' => $eula,
|
||||
'req_accept' => $req_accept,
|
||||
'accept_url' => $accept_url,
|
||||
|
||||
+105
-18
@@ -9,8 +9,11 @@ use App\Presenters\ActionlogPresenter;
|
||||
use App\Presenters\Presentable;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
@@ -52,6 +55,13 @@ class Actionlog extends SnipeModel
|
||||
|
||||
use Searchable;
|
||||
|
||||
/**
|
||||
* Cache whether a model table has a company_id column.
|
||||
*
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
protected static array $companyColumnCache = [];
|
||||
|
||||
/**
|
||||
* The attributes that should be included when searching the model.
|
||||
*
|
||||
@@ -115,25 +125,81 @@ class Actionlog extends SnipeModel
|
||||
public static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(
|
||||
function (self $actionlog) {
|
||||
// If the admin is a superadmin, let's see if the target instead has a company.
|
||||
if (auth()->user() && auth()->user()->isSuperUser()) {
|
||||
if ($actionlog->target) {
|
||||
$actionlog->company_id = $actionlog->target->company_id;
|
||||
} elseif ($actionlog->item) {
|
||||
$actionlog->company_id = $actionlog->item->company_id;
|
||||
}
|
||||
} elseif (auth()->user() && auth()->user()->company) {
|
||||
$actionlog->company_id = auth()->user()->company_id;
|
||||
}
|
||||
|
||||
if ($actionlog->action_date == '') {
|
||||
$actionlog->action_date = Carbon::now();
|
||||
}
|
||||
|
||||
static::creating(function (self $actionlog): void {
|
||||
// Only resolve company_id if it was never explicitly set by the caller.
|
||||
// Using array_key_exists on getRawOriginal() / getAttributes() lets us
|
||||
// distinguish "was set to null intentionally" from "was never set at all".
|
||||
if (! array_key_exists('company_id', $actionlog->getAttributes())) {
|
||||
$actionlog->company_id = static::resolveCompanyIdFromAttributes(
|
||||
$actionlog->target_type,
|
||||
$actionlog->target_id,
|
||||
$actionlog->item_type,
|
||||
$actionlog->item_id,
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if ($actionlog->action_date == '') {
|
||||
$actionlog->action_date = Carbon::now();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company_id for a new action log by querying the item model
|
||||
* directly, bypassing all global scopes to avoid FMCS filtering issues.
|
||||
*
|
||||
* We intentionally prefer the item (asset, license, etc.) over the target
|
||||
* (user, location) because FMCS visibility is based on who *owns* the item,
|
||||
* not who it was checked out to. If the item has no company_id we fall back
|
||||
* to the target so that logs on unowned items still get a company stamp where
|
||||
* possible.
|
||||
*
|
||||
* This has to include an exception for the asset models table, since they are
|
||||
* not company-constrained (on purpose.)
|
||||
*/
|
||||
protected static function resolveCompanyIdFromAttributes(
|
||||
?string $targetType,
|
||||
?int $targetId,
|
||||
?string $itemType,
|
||||
?int $itemId,
|
||||
): ?int {
|
||||
// Prefer the item (the thing being acted upon) for FMCS ownership.
|
||||
$companyId = static::resolveCompanyIdFromModelClass($itemType, $itemId);
|
||||
|
||||
if ($companyId !== null) {
|
||||
return $companyId;
|
||||
}
|
||||
|
||||
// Fall back to target only when the item has no company_id.
|
||||
return static::resolveCompanyIdFromModelClass($targetType, $targetId);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve company_id from a model class and ID, but only if that model's
|
||||
* table has a company_id column.
|
||||
*/
|
||||
protected static function resolveCompanyIdFromModelClass(?string $modelClass, ?int $id): ?int
|
||||
{
|
||||
if (! $modelClass || ! $id || ! class_exists($modelClass) || ! is_subclass_of($modelClass, Model::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Model $instance */
|
||||
$instance = app($modelClass);
|
||||
$table = $instance->getTable();
|
||||
|
||||
$hasCompanyColumn = static::$companyColumnCache[$table]
|
||||
??= Schema::hasColumn($table, 'company_id');
|
||||
|
||||
if (! $hasCompanyColumn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $modelClass::withoutGlobalScopes()
|
||||
->whereKey($id)
|
||||
->value('company_id');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,6 +394,27 @@ class Actionlog extends SnipeModel
|
||||
return $this->morphTo('target')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Eager load history relations used by the API transformer to avoid N+1 queries.
|
||||
*/
|
||||
public function scopeForApiHistory($query)
|
||||
{
|
||||
return $query->with([
|
||||
'adminuser',
|
||||
'location',
|
||||
'item' => function (MorphTo $morphTo) {
|
||||
$morphTo->morphWith([
|
||||
Asset::class => ['model'],
|
||||
]);
|
||||
},
|
||||
'target' => function (MorphTo $morphTo) {
|
||||
$morphTo->morphWith([
|
||||
Asset::class => ['model'],
|
||||
]);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the actionlog -> location relationship
|
||||
*
|
||||
|
||||
+69
-7
@@ -228,6 +228,8 @@ class Asset extends Depreciable
|
||||
protected $searchableRelationAliases = [
|
||||
'status_label' => 'status',
|
||||
'assigned_to' => 'assignedTo',
|
||||
'model_number' => 'model',
|
||||
'rtd_location' => 'defaultLoc',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
@@ -516,7 +518,7 @@ class Asset extends Depreciable
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function checkOut($target, $admin = null, $checkout_at = null, $expected_checkin = null, $note = null, $name = null, $location = null)
|
||||
public function checkOut($target, $admin = null, $checkout_at = null, $expected_checkin = null, $note = null, $name = null, $location = null, bool $signInPlace = false)
|
||||
{
|
||||
if (! $target) {
|
||||
return false;
|
||||
@@ -560,7 +562,7 @@ class Asset extends Depreciable
|
||||
} else {
|
||||
$checkedOutBy = auth()->user();
|
||||
}
|
||||
event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note, $originalValues));
|
||||
event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note, $originalValues, 1, $signInPlace));
|
||||
|
||||
$this->increment('checkout_counter', 1);
|
||||
|
||||
@@ -761,9 +763,25 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function assignedAccessories()
|
||||
{
|
||||
return $this->morphMany(AccessoryCheckout::class, 'assigned', 'assigned_type', 'assigned_to');
|
||||
return $this->morphMany(AccessoryCheckout::class, 'assigned', 'assigned_type', 'assigned_to')->with('accessory');
|
||||
}
|
||||
|
||||
public function accessories()
|
||||
{
|
||||
return $this->hasManyThrough(
|
||||
Accessory::class,
|
||||
AccessoryCheckout::class,
|
||||
'assigned_to',
|
||||
'id',
|
||||
'id',
|
||||
'accessory_id'
|
||||
)->where('assigned_type', self::class);
|
||||
}
|
||||
|
||||
// {
|
||||
// return $this->morphMany(AccessoryCheckout::class, 'assigned', 'assigned_type', 'assigned_to')->withTrashed();
|
||||
// }
|
||||
|
||||
/**
|
||||
* Get the asset's location based on the assigned user
|
||||
*
|
||||
@@ -1248,12 +1266,44 @@ class Asset extends Depreciable
|
||||
|
||||
public function getComponentCost()
|
||||
{
|
||||
$cost = 0;
|
||||
foreach ($this->components as $component) {
|
||||
$cost += $component->pivot->assigned_qty * $component->purchase_cost;
|
||||
return (float) $this->components->sum('calculated_purchase_cost');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return EOL progress percentage (0-100), based on elapsed months since
|
||||
* purchase date over the configured EOL window.
|
||||
*/
|
||||
public function eolProgressPercent(): float
|
||||
{
|
||||
if (! $this->purchase_date || ! $this->asset_eol_date) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $cost;
|
||||
return $this->calculateProgressPercent(
|
||||
start: Carbon::parse($this->purchase_date),
|
||||
end: Carbon::parse($this->asset_eol_date),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return warranty progress percentage (0-100), based on elapsed months
|
||||
* since purchase date over the warranty window.
|
||||
*/
|
||||
public function warrantyProgressPercent(): float
|
||||
{
|
||||
if (! $this->purchase_date || ! $this->warranty_expires) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $this->calculateProgressPercent(
|
||||
start: Carbon::parse($this->purchase_date),
|
||||
end: $this->warranty_expires,
|
||||
);
|
||||
}
|
||||
|
||||
public function getAccessoryCost()
|
||||
{
|
||||
return (float) $this->accessories()->sum('purchase_cost');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2118,4 +2168,16 @@ class Asset extends Depreciable
|
||||
->join('depreciations', 'models.depreciation_id', '=', 'depreciations.id')->where('models.depreciation_id', '=', $search);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the asset has an orphaned assignment where the assigned target no longer exists.
|
||||
* This occurs when:
|
||||
* 1. assigned_to is set but assigned_type is missing/null
|
||||
* 2. assigned_to and assigned_type are both set, but the relationship cannot be resolved (target was hard-deleted)
|
||||
*/
|
||||
public function hasOrphanedAssignment(): bool
|
||||
{
|
||||
return ($this->assigned_to && ! $this->assigned_type)
|
||||
|| ($this->assigned_to && $this->assigned_type && ! $this->assignedTo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,13 +243,27 @@ class CheckoutAcceptance extends Model
|
||||
if ($data['item_serial'] != null) {
|
||||
$pdf->writeHTML(trans('admin/hardware/form.serial').': '.e($data['item_serial']), true, 0, true, 0, '');
|
||||
}
|
||||
if (!empty($data['custom_fields']) && is_iterable($data['custom_fields'])) {
|
||||
foreach ($data['custom_fields'] as $customField) {
|
||||
$label = $customField['label'] ?? null;
|
||||
$value = $customField['value'] ?? null;
|
||||
|
||||
if (($label !== null) && ($value !== null) && ($value !== '')) {
|
||||
$pdf->writeHTML(e((string) $label) . ': ' . e((string) $value), true, 0, true, 0, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (($data['qty'] != null) && ($data['qty'] > 1)) {
|
||||
$pdf->writeHTML(trans('general.qty').': '.e($data['qty']), true, 0, true, 0, '');
|
||||
}
|
||||
$pdf->Ln();
|
||||
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
|
||||
$pdf->writeHTML(trans('general.assignee').': '.e($data['assigned_to']).($data['employee_num'] ? ' ('.$data['employee_num'].')' : ''), true, 0, true, 0, '');
|
||||
if ($data['email'] != null) {
|
||||
$pdf->writeHTML(trans('general.email').': '.e($data['email']), true, 0, true, 0, '');
|
||||
}
|
||||
|
||||
$pdf->Ln();
|
||||
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Traits\Loggable;
|
||||
use App\Models\Traits\Searchable;
|
||||
use App\Presenters\ComponentPresenter;
|
||||
use App\Presenters\Presentable;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
@@ -165,6 +166,26 @@ class Component extends SnipeModel
|
||||
return $this->belongsToMany(Asset::class, 'components_assets')->withPivot('id', 'assigned_qty', 'created_at', 'created_by', 'note');
|
||||
}
|
||||
|
||||
protected function calculatedPurchaseCost(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function ($value) {
|
||||
$unitPurchaseCost = $this->getRawOriginal('purchase_cost');
|
||||
$assignedQty = $this->pivot?->assigned_qty;
|
||||
|
||||
if ($unitPurchaseCost === null) {
|
||||
return $assignedQty !== null ? 0.0 : null;
|
||||
}
|
||||
|
||||
if ($assignedQty !== null) {
|
||||
return (float) $unitPurchaseCost * (int) $assignedQty;
|
||||
}
|
||||
|
||||
return (float) $unitPurchaseCost;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the component -> company relationship
|
||||
*
|
||||
@@ -232,7 +253,7 @@ class Component extends SnipeModel
|
||||
*/
|
||||
public function requireAcceptance()
|
||||
{
|
||||
return $this->category->require_acceptance;
|
||||
return $this->category?->require_acceptance ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,17 +40,17 @@ class ComponentAssignment extends Model
|
||||
*/
|
||||
public function component()
|
||||
{
|
||||
return $this->belongsTo(Component::class);
|
||||
return $this->belongsTo(Component::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function components()
|
||||
{
|
||||
return $this->hasMany(Component::class, 'id', 'component_id');
|
||||
return $this->hasMany(Component::class, 'id', 'component_id')->withTrashed();
|
||||
}
|
||||
|
||||
public function assets()
|
||||
{
|
||||
return $this->hasMany(Asset::class, 'id', 'asset_id');
|
||||
return $this->hasMany(Asset::class, 'id', 'asset_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -170,6 +170,10 @@ class Consumable extends SnipeModel
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (($this->qty == '') || ($this->qty == 0)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ($this->qty - $this->consumables_users_count) / $this->qty * 100;
|
||||
}
|
||||
|
||||
@@ -318,7 +322,7 @@ class Consumable extends SnipeModel
|
||||
*/
|
||||
public function requireAcceptance()
|
||||
{
|
||||
return $this->category->require_acceptance;
|
||||
return $this->category?->require_acceptance ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
class Depreciable extends SnipeModel
|
||||
{
|
||||
/**
|
||||
@@ -187,6 +189,39 @@ class Depreciable extends SnipeModel
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Return depreciation progress percentage (0-100), based on elapsed months
|
||||
* since purchase date over the depreciation window.
|
||||
*/
|
||||
public function depreciationProgressPercent(): float
|
||||
{
|
||||
if (! $this->purchase_date || ! $this->depreciated_date()) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $this->calculateProgressPercent(
|
||||
start: Carbon::parse($this->purchase_date),
|
||||
end: Carbon::instance($this->depreciated_date()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate elapsed/total month percentage and clamp to 0-100.
|
||||
*/
|
||||
protected function calculateProgressPercent(Carbon $start, Carbon $end): float
|
||||
{
|
||||
$totalMonths = (float) $start->diffInMonths($end);
|
||||
|
||||
if ($totalMonths <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$elapsedMonths = (float) $start->diffInMonths(Carbon::now());
|
||||
$rawPercent = ($elapsedMonths / $totalMonths) * 100;
|
||||
|
||||
return (float) min(100, max(0, $rawPercent));
|
||||
}
|
||||
|
||||
// it's necessary for unit tests
|
||||
protected function getDateTime($time = null)
|
||||
{
|
||||
|
||||
@@ -81,6 +81,12 @@ class Group extends SnipeModel
|
||||
return $this->belongsToMany(User::class, 'users_groups');
|
||||
}
|
||||
|
||||
/* this is just a shim for SCIM to work */
|
||||
public function members()
|
||||
{
|
||||
return $this->users();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JSON permissions into array
|
||||
*
|
||||
|
||||
@@ -68,17 +68,8 @@ class DefaultLabel extends RectangleSheet
|
||||
$usableWidth = $this->pageWidth - $this->pageMarginLeft - $this->pageMarginRight;
|
||||
$usableHeight = $this->pageHeight - $this->pageMarginTop - $this->pageMarginBottom;
|
||||
|
||||
$this->columns = ($usableWidth + $this->labelSpacingH) / ($this->labelWidth + $this->labelSpacingH);
|
||||
$this->rows = ($usableHeight + $this->labelSpacingV) / ($this->labelHeight + $this->labelSpacingV);
|
||||
|
||||
// Make sure the columns and rows are never zero, since that scenario should never happen
|
||||
if ($this->columns == 0) {
|
||||
$this->columns = 1;
|
||||
}
|
||||
|
||||
if ($this->rows == 0) {
|
||||
$this->rows = 1;
|
||||
}
|
||||
$this->columns = $this->calculateGridCount($usableWidth, $this->labelWidth, $this->labelSpacingH);
|
||||
$this->rows = $this->calculateGridCount($usableHeight, $this->labelHeight, $this->labelSpacingV);
|
||||
|
||||
}
|
||||
|
||||
@@ -299,4 +290,17 @@ class DefaultLabel extends RectangleSheet
|
||||
|
||||
return $labelHeight;
|
||||
}
|
||||
|
||||
private function calculateGridCount(float $usableSize, float $labelSize, float $spacing): int
|
||||
{
|
||||
$denominator = $labelSize + $spacing;
|
||||
|
||||
if ($denominator <= 0.0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$count = (int) floor(($usableSize + $spacing) / $denominator);
|
||||
|
||||
return max(1, $count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,11 +640,11 @@ class License extends Depreciable
|
||||
/**
|
||||
* This is really dumb - needs to be refactored, since we have ~3 diff methods that do almost the same thing
|
||||
*
|
||||
* @author A. Gianotto <snipe@snipe.net>
|
||||
* @return int
|
||||
*
|
||||
* @since [v2.0]
|
||||
*
|
||||
* @return Relation
|
||||
* @author A. Gianotto <snipe@snipe.net>
|
||||
*/
|
||||
public function numRemaining()
|
||||
{
|
||||
|
||||
@@ -180,6 +180,11 @@ class Location extends SnipeModel
|
||||
);
|
||||
}
|
||||
|
||||
public function countAllTheThings()
|
||||
{
|
||||
return $this->assets()->count() + $this->consumables()->count() + $this->components()->count() + $this->users()->count() + $this->assignedAccessories()->count() + $this->assignedAssets()->count() + $this->accessories()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the asset -> rtd_location relationship
|
||||
*
|
||||
|
||||
@@ -32,6 +32,8 @@ class Maintenance extends SnipeModel implements ICompanyableChild
|
||||
|
||||
protected $presenter = MaintenancesPresenter::class;
|
||||
|
||||
protected $with = ['asset', 'asset.company'];
|
||||
|
||||
protected $table = 'maintenances';
|
||||
|
||||
protected $rules = [
|
||||
|
||||
@@ -13,4 +13,12 @@ class SCIMUser extends User
|
||||
$attributes['password'] = $this->noPassword();
|
||||
parent::__construct($attributes);
|
||||
}
|
||||
}
|
||||
|
||||
// Have to re-define this here because Eloquent will try to 'guess' a foreign key of s_c_i_m_user_id
|
||||
// from SCIMUser
|
||||
public function groups()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\Group::class, 'users_groups', 'user_id', 'group_id');
|
||||
}
|
||||
|
||||
}
|
||||
+460
-211
@@ -2,248 +2,497 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Helper;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Attribute;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Collection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Complex;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Constant;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Eloquent;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\JSONCollection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Meta;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\MutableCollection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Schema as AttributeSchema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
|
||||
|
||||
class SnipeSCIMConfig extends SCIMConfig
|
||||
function a($name = null): Attribute
|
||||
{
|
||||
return new Attribute($name);
|
||||
}
|
||||
|
||||
function complex($name = null): Complex
|
||||
{
|
||||
return new Complex($name);
|
||||
}
|
||||
|
||||
function eloquent($name, $attribute = null): Attribute
|
||||
{
|
||||
return new Eloquent($name, $attribute);
|
||||
}
|
||||
|
||||
class EloquentWithRemove extends Eloquent
|
||||
{
|
||||
public function remove($value, Model &$object, Path $path = null)
|
||||
{
|
||||
$object->{$this->attribute} = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class MappedTable extends Attribute
|
||||
{
|
||||
public function __construct(
|
||||
private string $scim_attribute_name,
|
||||
private string $relationship_name,
|
||||
private string $relationship_class,
|
||||
private string $relationship_id_field,
|
||||
private string $relationship_field)
|
||||
{
|
||||
parent::__construct($this->scim_attribute_name);
|
||||
}
|
||||
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return $object->{$this->relationship_name}?->{$this->relationship_field};
|
||||
}
|
||||
|
||||
public function add($value, Model &$object)
|
||||
{
|
||||
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
|
||||
}
|
||||
|
||||
public function replace($value, Model &$object, $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
|
||||
}
|
||||
|
||||
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class UpdatableComplex extends Complex
|
||||
{
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
throw new \Exception("doWrite is not implemented yet for Operation: $operation " . ($subop ? "($subop)" : "") . "on attribute " . $this->getFullKey());
|
||||
}
|
||||
|
||||
public function add($value, Model &$object)
|
||||
{
|
||||
$this->doWrite("add", null, $value, $object);
|
||||
}
|
||||
|
||||
public function replace($value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->doWrite("replace", null, $value, $object, $path, $removeIfNotSet);
|
||||
}
|
||||
|
||||
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->doWrite("patch", $operation, $value, $object, $path, $removeIfNotSet);
|
||||
}
|
||||
|
||||
public function remove($value, Model &$object, Path $path = null)
|
||||
{
|
||||
$this->doWrite("remove", null, null, $object, $path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SnipeSCIMConfig
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function getConfigForResource($name)
|
||||
{
|
||||
$result = $this->getConfig();
|
||||
return @$result[$name];
|
||||
}
|
||||
|
||||
public function getGroupClass()
|
||||
{
|
||||
return Group::class;
|
||||
}
|
||||
|
||||
const ENTERPRISE = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User';
|
||||
const GROKABILITY = 'urn:ietf:params:scim:schemas:extension:grokability:2.0:User';
|
||||
|
||||
public function getUserConfig()
|
||||
{
|
||||
// Much of this is copied verbatim from the library, then adjusted for our needs
|
||||
|
||||
/*
|
||||
more snipe-it attributes I'd like to check out (to map to 'enterprise' maybe?):
|
||||
- website
|
||||
- notes?
|
||||
- remote???
|
||||
- location_id ?
|
||||
- company_id to "organization?"
|
||||
*/
|
||||
|
||||
$user_prefix = 'urn:ietf:params:scim:schemas:core:2.0:User:';
|
||||
$enterprise_prefix = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:';
|
||||
|
||||
return [
|
||||
|
||||
// Set to 'null' to make use of auth.providers.users.model (App\User::class)
|
||||
'class' => SCIMUser::class,
|
||||
|
||||
'validations' => [
|
||||
$user_prefix.'userName' => 'required',
|
||||
$user_prefix.'displayName' => 'nullable|string',
|
||||
$user_prefix.'name.givenName' => 'required',
|
||||
$user_prefix.'name.familyName' => 'nullable|string',
|
||||
$user_prefix.'externalId' => 'nullable|string',
|
||||
$user_prefix.'emails' => 'nullable|array',
|
||||
$user_prefix.'emails.*.value' => 'nullable|email',
|
||||
$user_prefix.'active' => 'boolean',
|
||||
$user_prefix.'phoneNumbers' => 'nullable|array',
|
||||
$user_prefix.'phoneNumbers.*.value' => 'nullable|string',
|
||||
$user_prefix.'addresses' => 'nullable|array',
|
||||
$user_prefix.'addresses.*.streetAddress' => 'nullable|string',
|
||||
$user_prefix.'addresses.*.locality' => 'nullable|string',
|
||||
$user_prefix.'addresses.*.region' => 'nullable|string',
|
||||
$user_prefix.'addresses.*.postalCode' => 'nullable|string',
|
||||
$user_prefix.'addresses.*.country' => 'nullable|string',
|
||||
$user_prefix.'title' => 'nullable|string',
|
||||
$user_prefix.'preferredLanguage' => 'nullable|string',
|
||||
|
||||
// Enterprise validations:
|
||||
$enterprise_prefix.'employeeNumber' => 'nullable|string',
|
||||
$enterprise_prefix.'department' => 'nullable|string',
|
||||
$enterprise_prefix.'manager' => 'nullable',
|
||||
$enterprise_prefix.'manager.value' => 'nullable|string',
|
||||
],
|
||||
|
||||
'singular' => 'User',
|
||||
'schema' => [Schema::SCHEMA_USER],
|
||||
|
||||
// eager loading
|
||||
'withRelations' => [],
|
||||
'map_unmapped' => false,
|
||||
// 'unmapped_namespace' => 'urn:ietf:params:scim:schemas:laravel:unmapped',
|
||||
'description' => 'User Account',
|
||||
|
||||
// Map a SCIM attribute to an attribute of the object.
|
||||
'mapping' => [
|
||||
|
||||
'id' => (new AttributeMapping)->setRead(
|
||||
function (&$object) {
|
||||
return (string) $object->id;
|
||||
'map' => complex()->withSubAttributes(
|
||||
new class ('schemas', [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
self::ENTERPRISE,
|
||||
self::GROKABILITY
|
||||
]) extends Constant {
|
||||
public function replace($value, &$object, $path = null)
|
||||
{
|
||||
// do nothing
|
||||
$this->dirty = true;
|
||||
}
|
||||
},
|
||||
(new class ('id', null) extends Constant { // TODO - this 'id' is in the same namespace for objects OR groups?
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (string)$object->id;
|
||||
}
|
||||
)->disableWrite(),
|
||||
|
||||
'externalId' => AttributeMapping::eloquent('scim_externalid'), // FIXME - I have a PR that changes a lot of this.
|
||||
|
||||
'meta' => [
|
||||
'created' => AttributeMapping::eloquent('created_at')->disableWrite(),
|
||||
'lastModified' => AttributeMapping::eloquent('updated_at')->disableWrite(),
|
||||
|
||||
'location' => (new AttributeMapping)->setRead(
|
||||
function ($object) {
|
||||
return route(
|
||||
'scim.resource',
|
||||
[
|
||||
'resourceType' => 'Users',
|
||||
'resourceObject' => $object->id,
|
||||
]
|
||||
);
|
||||
public function remove($value, &$object, $path = null)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
),
|
||||
new Meta('Users'),
|
||||
(new AttributeSchema(Schema::SCHEMA_USER, true))->withSubAttributes(
|
||||
eloquent('userName', 'username')->ensure('required'),
|
||||
(new class ('active', 'activated') extends Eloquent {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (bool)$object->activated; // need this extension to force boolean-ness
|
||||
}
|
||||
)->disableWrite(),
|
||||
}),
|
||||
complex('name')->withSubAttributes(
|
||||
eloquent('givenName', 'first_name')->ensure('required'),
|
||||
eloquent('familyName', 'last_name'),
|
||||
), // ->ensure('required'), It *is* a bit weird, but I would've thought 'name' is required since 'givenName' is required? But apparently not?
|
||||
eloquent('displayName', 'display_name'), //yes, this is *not* under 'name' - that's the spec
|
||||
//eloquent('password')->ensure('nullable')->setReturned('never'),
|
||||
eloquent('externalId', 'scim_externalid'),
|
||||
|
||||
'resourceType' => AttributeMapping::constant('User'),
|
||||
],
|
||||
|
||||
'schemas' => AttributeMapping::constant(
|
||||
[
|
||||
'urn:ietf:params:scim:schemas:core:2.0:User',
|
||||
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User',
|
||||
]
|
||||
)->ignoreWrite(),
|
||||
|
||||
'urn:ietf:params:scim:schemas:core:2.0:User' => [
|
||||
|
||||
'userName' => AttributeMapping::eloquent('username'),
|
||||
|
||||
'name' => [
|
||||
'formatted' => (new AttributeMapping)->ignoreWrite()->setRead(
|
||||
function (&$object) {
|
||||
return $object->getFullNameAttribute();
|
||||
}
|
||||
),
|
||||
'familyName' => AttributeMapping::eloquent('last_name'),
|
||||
'givenName' => AttributeMapping::eloquent('first_name'),
|
||||
'middleName' => null,
|
||||
'honorificPrefix' => null,
|
||||
'honorificSuffix' => null,
|
||||
],
|
||||
|
||||
'displayName' => AttributeMapping::eloquent('display_name'),
|
||||
'nickName' => null,
|
||||
'profileUrl' => null,
|
||||
'title' => AttributeMapping::eloquent('jobtitle'),
|
||||
'userType' => null,
|
||||
'preferredLanguage' => AttributeMapping::eloquent('locale'), // Section 5.3.5 of [RFC7231]
|
||||
'locale' => null, // see RFC5646
|
||||
'timezone' => null, // see RFC6557
|
||||
'active' => (new AttributeMapping)->setAdd(
|
||||
function ($value, &$object) {
|
||||
$object->activated = $value;
|
||||
// Email chonk
|
||||
(new class ('emails') extends UpdatableComplex {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return collect([$object->email])->map(function ($email) {
|
||||
return [
|
||||
'value' => $email,
|
||||
'type' => 'work', //TODO - is this how we always have done it?
|
||||
'primary' => true
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
)->setReplace(
|
||||
function ($value, &$object) {
|
||||
$object->activated = $value;
|
||||
}
|
||||
)->setRead(
|
||||
// this works as specified.
|
||||
function (&$object) {
|
||||
return (bool) $object->activated;
|
||||
}
|
||||
),
|
||||
'password' => AttributeMapping::eloquent('password')->disableRead(),
|
||||
|
||||
// Multi-Valued Attributes
|
||||
'emails' => [[
|
||||
'value' => AttributeMapping::eloquent('email'),
|
||||
'display' => null,
|
||||
'type' => AttributeMapping::constant('work')->ignoreWrite(),
|
||||
'primary' => AttributeMapping::constant(true)->ignoreWrite(),
|
||||
]],
|
||||
|
||||
'phoneNumbers' => [[
|
||||
'value' => AttributeMapping::eloquent('phone'),
|
||||
'display' => null,
|
||||
'type' => AttributeMapping::constant('work')->ignoreWrite(),
|
||||
'primary' => AttributeMapping::constant(true)->ignoreWrite(),
|
||||
]],
|
||||
|
||||
'ims' => [[
|
||||
'value' => null,
|
||||
'display' => null,
|
||||
'type' => null,
|
||||
'primary' => null,
|
||||
]], // Instant messaging addresses for the User
|
||||
|
||||
'photos' => [[
|
||||
'value' => null,
|
||||
'display' => null,
|
||||
'type' => null,
|
||||
'primary' => null,
|
||||
]],
|
||||
|
||||
'addresses' => [[
|
||||
'type' => AttributeMapping::constant('work')->ignoreWrite(),
|
||||
'formatted' => AttributeMapping::constant('n/a')->ignoreWrite(), // TODO - is this right? This doesn't look right.
|
||||
'streetAddress' => AttributeMapping::eloquent('address'),
|
||||
'locality' => AttributeMapping::eloquent('city'),
|
||||
'region' => AttributeMapping::eloquent('state'),
|
||||
'postalCode' => AttributeMapping::eloquent('zip'),
|
||||
'country' => AttributeMapping::eloquent('country'),
|
||||
'primary' => AttributeMapping::constant(true)->ignoreWrite(), // this isn't in the example?
|
||||
]],
|
||||
|
||||
'groups' => [[
|
||||
'value' => null,
|
||||
'$ref' => null,
|
||||
'display' => null,
|
||||
'type' => null,
|
||||
]],
|
||||
|
||||
'entitlements' => null,
|
||||
'roles' => null,
|
||||
'x509Certificates' => null,
|
||||
],
|
||||
|
||||
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => [
|
||||
'employeeNumber' => AttributeMapping::eloquent('employee_num'),
|
||||
'department' => (new AttributeMapping)->setAdd( // FIXME parent?
|
||||
function ($value, &$object) {
|
||||
$department = Department::where('name', $value)->first();
|
||||
if ($department) {
|
||||
$object->department_id = $department->id;
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
if ($value) {
|
||||
try {
|
||||
$object->email = $value[0]['value'];
|
||||
} catch (\Throwable $e) {
|
||||
\Log::debug($e);
|
||||
throw new SCIMException("Unknown email object: '" . print_r($value, true) . "'", 422);
|
||||
}
|
||||
} else {
|
||||
$object->email = null;
|
||||
}
|
||||
}
|
||||
)->setReplace(
|
||||
function ($value, &$object) {
|
||||
$department = Department::where('name', $value)->first();
|
||||
if ($department) {
|
||||
$object->department_id = $department->id;
|
||||
})->withSubAttributes(
|
||||
eloquent('value', 'email')->ensure('email', 'nullable'), //Weird, this 'needs' nullable to work?
|
||||
new Constant('type', 'work'),
|
||||
(new Constant('primary', true))->ensure('boolean')
|
||||
)->ensure('array')
|
||||
->setMultiValued(true),
|
||||
|
||||
// phone chonk
|
||||
(new class ('phoneNumbers') extends UpdatableComplex {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
$phones = [];
|
||||
if ($object->phone) {
|
||||
$phones[] = [
|
||||
'value' => $object->phone,
|
||||
'type' => 'work'
|
||||
];
|
||||
|
||||
}
|
||||
if ($object->mobile) {
|
||||
$phones[] = [
|
||||
'value' => $object->mobile,
|
||||
'type' => 'mobile'
|
||||
];
|
||||
}
|
||||
return $phones;
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
\Log::debug("Phones 'value' is: " . print_r($value, true));
|
||||
try {
|
||||
if ($operation == "patch") {
|
||||
if ($path->getValuePathFilter() != null) {
|
||||
if ((string) $path == 'phoneNumbers[type eq "mobile"].value') {
|
||||
$object->mobile = $value; //I don't know why the value is the raw value, but it is?
|
||||
return;
|
||||
}
|
||||
if ((string) $path == 'phoneNumbers[type eq "work"].value') {
|
||||
$object->phone = $value; //similar, don't know why, but it is
|
||||
return;
|
||||
}
|
||||
}
|
||||
parent::patch($subop, $value, $object, $path, $removeIfNotSet);
|
||||
return;
|
||||
}
|
||||
foreach ($value as $phone) {
|
||||
switch ($phone['type']) {
|
||||
case 'work':
|
||||
$object->phone = $phone['value'];
|
||||
break;
|
||||
|
||||
case 'mobile':
|
||||
$object->mobile = $phone['value'];
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new SCIMException("Unknown phone type '" . @$phone['type'] . "'", 400);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::debug($e);
|
||||
throw new SCIMException("Unknown phone object(s) '" . print_r($value, true) . "'", 422);
|
||||
}
|
||||
}
|
||||
)->setRead(
|
||||
function (&$object) {
|
||||
return $object->department ? $object->department->name : null;
|
||||
}
|
||||
),
|
||||
'manager' => [
|
||||
// FIXME - manager writes are disabled. This kinda works but it leaks errors all over the place. Not cool.
|
||||
// '$ref' => (new AttributeMapping())->ignoreWrite()->ignoreRead(),
|
||||
// 'displayName' => (new AttributeMapping())->ignoreWrite()->ignoreRead(),
|
||||
// NOTE: you could probably do a 'plain' Eloquent mapping here, but we don't for future-proofing
|
||||
'value' => (new AttributeMapping)->setAdd(
|
||||
function ($value, &$object) {
|
||||
$manager = User::find($value);
|
||||
if ($manager) {
|
||||
$object->manager_id = $manager->id;
|
||||
|
||||
})->withSubAttributes( // TODO: I suspect these 'sub-attributes' aren't being checked at all
|
||||
(new Constant('value', 'email'))->ensure('string'), // TODO - this is WRONG, but it works somehow? Probably because it's ignored
|
||||
new Constant('type', 'other'), // TODO uh, *also* wrong? but, again, seems to be ignored
|
||||
)->ensure('array')
|
||||
->setMultiValued(true),
|
||||
|
||||
// addresses chonk
|
||||
(new class ('addresses') extends UpdatableComplex {
|
||||
static $addressmap = [
|
||||
'streetAddress' => 'address',
|
||||
'locality' => 'city',
|
||||
'region' => 'state',
|
||||
'postalCode' => 'zip',
|
||||
'country' => 'country'
|
||||
];
|
||||
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
$address = [];
|
||||
foreach (self::$addressmap as $scim_field => $db_field) {
|
||||
if ($object->{$db_field}) {
|
||||
$address[$scim_field] = $object->{$db_field};
|
||||
}
|
||||
}
|
||||
)->setReplace(
|
||||
function ($value, &$object) {
|
||||
$manager = User::find($value);
|
||||
if ($manager) {
|
||||
$object->manager_id = $manager->id;
|
||||
if (count($address) > 0) {
|
||||
$address['type'] = 'work';
|
||||
$address['primary'] = true;
|
||||
}
|
||||
return $address;
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
// TODO - this is validated *just* for 'patch' operations, so this may not work in other write contexts
|
||||
if ($path->getValuePathFilter() != null) {
|
||||
\Log::debug("path for update $path");
|
||||
// get the part of the $path that we actually care about - something like:
|
||||
// addresses[type eq "work"]
|
||||
$matches = null;
|
||||
if (!preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string)$path, $matches)) {
|
||||
throw new SCIMException("Unknown path type '$path'", 422);
|
||||
}
|
||||
$type = $matches[1];
|
||||
if ($type != 'work') {
|
||||
throw new SCIMException("Unknown object type '$type'", 422);
|
||||
}
|
||||
$attribute = array_key_exists(2, $matches) ? $matches[2] : null;
|
||||
if (array_key_exists($attribute, self::$addressmap)) {
|
||||
$object->{self::$addressmap[$attribute]} = $value;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
throw new SCIMException("Could not handle path for update $path", 422);
|
||||
}
|
||||
)->setRead(
|
||||
function (&$object) {
|
||||
return $object->manager_id;
|
||||
}
|
||||
|
||||
})->withSubAttributes(
|
||||
eloquent('streetAddress', 'address'),
|
||||
eloquent('locality', 'city'),
|
||||
eloquent('region', 'state'),
|
||||
eloquent('postalCode', 'zip'),
|
||||
eloquent('country', 'country'),
|
||||
new Constant('type', 'other'),
|
||||
(new Constant('primary', true))->ensure('boolean')
|
||||
)->ensure('array')
|
||||
->setMultiValued(true),
|
||||
|
||||
eloquent('title', 'jobtitle'),
|
||||
eloquent('preferredLanguage', 'locale'),
|
||||
(new Collection('groups'))->withSubAttributes(
|
||||
eloquent('value', 'id'),
|
||||
(new class ('$ref') extends Eloquent {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return route(
|
||||
'scim.resource',
|
||||
[
|
||||
'resourceType' => 'Group',
|
||||
'resourceObject' => $object->id ?? "not-saved"
|
||||
]
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
}),
|
||||
eloquent('display', 'name')
|
||||
),
|
||||
(new JSONCollection('roles'))->withSubAttributes( // TODO - what is this?
|
||||
eloquent('value')->ensure('required', 'min:3', 'alpha_dash:ascii'),
|
||||
eloquent('display')->ensure('nullable', 'min:3', 'alpha_dash:ascii'),
|
||||
eloquent('type')->ensure('nullable', 'min:3', 'alpha_dash:ascii'),
|
||||
eloquent('primary')->ensure('boolean')->default(false)
|
||||
)->ensure('nullable', 'array', 'max:20')
|
||||
),
|
||||
(new AttributeSchema(self::ENTERPRISE, false))->withSubAttributes(
|
||||
eloquent('employeeNumber', 'employee_num')->ensure('nullable'),
|
||||
new MappedTable('department', 'department', Department::class, 'department_id', 'name'),
|
||||
(new class('manager') extends UpdatableComplex {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
if (!$object->manager) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'value' => $object->manager->id, //TODO - ID's aren't unique like they're supposed to be :/
|
||||
'$ref' => route('scim.resource', ['resourceType' => 'User', 'resourceObject' => $object->manager->id]),
|
||||
'displayName' => $object->manager->display_name,
|
||||
];
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
\Log::debug("What type of value is value? " . gettype($value));
|
||||
$manager_id = null;
|
||||
if (is_scalar($value)) {
|
||||
\Log::debug("Weird Microsoft mode - set manager to the \$value and move on with life?");
|
||||
$manager_id = $value;
|
||||
} elseif (array_key_exists('$ref', $value)) {
|
||||
// Here's the spec: https://datatracker.ietf.org/doc/html/rfc7643#section-4.3
|
||||
|
||||
// according to the spec it's _recommended_ to do:
|
||||
// $ref - which should be the URI of the manager
|
||||
|
||||
// extract ID from URL, jam it in?
|
||||
$url = $value['$ref'];
|
||||
$users_prefix = route('scim.resources', ['resourceType' => 'User']) . '/';
|
||||
if (string_starts_with($url, $users_prefix)) {
|
||||
$manager_id = substr($url, strlen($users_prefix));
|
||||
}
|
||||
} elseif (array_key_exists('value', $value)) {
|
||||
// this is _Snipe-IT_'s ID being passed as 'value' I believe?
|
||||
// if you use the 'managerId' field in Okta, you get:
|
||||
// [value] => 9999999
|
||||
// that, at least, is the spec - but *what* ID is that?! It's supposed to be a Snipe-IT one!
|
||||
$manager_id = $value['value'];
|
||||
}
|
||||
\Log::debug("Non-Microsoft - Trying to '$operation' for manager with value: " . print_r($value, true));
|
||||
if ($manager_id && User::find($manager_id)) {
|
||||
$object->manager_id = $manager_id;
|
||||
return;
|
||||
}
|
||||
throw new SCIMException("No manager given, or manager doesn't exist", 400);
|
||||
}
|
||||
}) // ->withSubAttributes() ... -> ensure() ?
|
||||
),
|
||||
(new AttributeSchema(self::GROKABILITY, false))->withSubAttributes(
|
||||
new MappedTable('location', 'location', Location::class, 'location_id', 'name'),
|
||||
new MappedTable('company', 'company', Company::class, 'company_id', 'name'),
|
||||
)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function getGroupConfig()
|
||||
{
|
||||
return [
|
||||
|
||||
'class' => $this->getGroupClass(),
|
||||
'singular' => 'Group',
|
||||
|
||||
//eager loading
|
||||
'withRelations' => [],
|
||||
'description' => 'Group',
|
||||
|
||||
'map' => complex()->withSubAttributes(
|
||||
new class ('schemas', [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:Group",
|
||||
]) extends Constant {
|
||||
public function replace($value, &$object, $path = null)
|
||||
{
|
||||
// do nothing
|
||||
$this->dirty = true;
|
||||
}
|
||||
},
|
||||
(new class ('id', null) extends Constant {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (string)$object->id;
|
||||
}
|
||||
|
||||
public function remove($value, &$object, $path = null)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
),
|
||||
new EloquentWithRemove('externalId', 'scim_externalid'),
|
||||
new Meta('Groups'),
|
||||
(new AttributeSchema(Schema::SCHEMA_GROUP, true))->withSubAttributes(
|
||||
eloquent('displayName', 'name')->ensure('required', 'min:3', function ($attribute, $value, $fail) {
|
||||
// check if group does not exist or if it exists, it is the same group
|
||||
$group = $this->getGroupClass()::where('name', $value)->first();
|
||||
if ($group && (request()->route('resourceObject') == null || $group->id != request()->route('resourceObject')->id)) {
|
||||
$fail('The name has already been taken.');
|
||||
}
|
||||
}),
|
||||
(new MutableCollection('members'))->withSubAttributes(
|
||||
eloquent('value', 'id')->ensure('required'),
|
||||
(new class ('$ref') extends Eloquent {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return route(
|
||||
'scim.resource',
|
||||
[
|
||||
'resourceType' => 'Users',
|
||||
'resourceObject' => $object->id ?? "not-saved"
|
||||
]
|
||||
);
|
||||
}
|
||||
}),
|
||||
eloquent('display', 'name')
|
||||
)->ensure('nullable', 'array')
|
||||
)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function getConfig()
|
||||
{
|
||||
return [
|
||||
'Users' => $this->getUserConfig(),
|
||||
'Groups' => $this->getGroupConfig(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models\Traits;
|
||||
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\ICompanyableChild;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Location;
|
||||
@@ -107,7 +108,7 @@ trait Loggable
|
||||
break;
|
||||
}
|
||||
|
||||
return $history;
|
||||
return $history->forApiHistory();
|
||||
|
||||
}
|
||||
|
||||
@@ -177,10 +178,11 @@ trait Loggable
|
||||
$log->note = $note;
|
||||
$log->action_date = $action_date;
|
||||
$log->quantity = $quantity;
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
|
||||
$changed = [];
|
||||
$array_to_flip = array_keys($fields_array);
|
||||
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin']);
|
||||
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin', 'requestable']);
|
||||
$originalValues = array_intersect_key($originalValues, array_flip($array_to_flip));
|
||||
|
||||
foreach ($originalValues as $key => $value) {
|
||||
@@ -221,6 +223,37 @@ trait Loggable
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company_id that should be stamped on an action log entry.
|
||||
*
|
||||
* LicenseSeat does not carry a company_id directly — it belongs to a License,
|
||||
* so we fetch the parent license's company_id in that case. All other models
|
||||
* that use the Loggable trait have a company_id column directly.
|
||||
*/
|
||||
private function resolveLoggableCompanyId(): ?int
|
||||
{
|
||||
if (static::class === LicenseSeat::class) {
|
||||
return $this->license?->company_id;
|
||||
}
|
||||
|
||||
if (isset($this->company_id)) {
|
||||
return $this->company_id;
|
||||
}
|
||||
|
||||
// Companyable children (like Maintenance) inherit company visibility from parents.
|
||||
if ($this instanceof ICompanyableChild) {
|
||||
foreach ((array) $this->getCompanyableParents() as $parentRelation) {
|
||||
$parent = $this->{$parentRelation} ?? null;
|
||||
|
||||
if (isset($parent?->company_id)) {
|
||||
return $parent->company_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @author Daniel Meltzer <dmeltzer.devel@gmail.com>
|
||||
*
|
||||
@@ -267,6 +300,7 @@ trait Loggable
|
||||
$log->location_id = null;
|
||||
$log->note = $note;
|
||||
$log->action_date = $action_date;
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
|
||||
if (! $action_date) {
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
@@ -279,7 +313,7 @@ trait Loggable
|
||||
$changed = [];
|
||||
|
||||
$array_to_flip = array_keys($fields_array);
|
||||
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin']);
|
||||
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin', 'requestable']);
|
||||
|
||||
$originalValues = array_intersect_key($originalValues, array_flip($array_to_flip));
|
||||
|
||||
@@ -303,6 +337,32 @@ trait Loggable
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a force checkin action for orphaned assignments.
|
||||
*
|
||||
* Force checkin only records an explicit action log entry and intentionally
|
||||
* skips checkin counters and changed-field metadata.
|
||||
*
|
||||
* @return Actionlog
|
||||
*/
|
||||
public function logForceCheckin($note = null)
|
||||
{
|
||||
$log = new Actionlog;
|
||||
|
||||
$log = $this->determineLogItemType($log);
|
||||
$log->location_id = null;
|
||||
$log->note = $note;
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
|
||||
if (auth()->user()) {
|
||||
$log->created_by = auth()->id();
|
||||
}
|
||||
|
||||
$log->logaction('force checkin');
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* @author A. Gianotto <snipe@snipe.net>
|
||||
*
|
||||
@@ -357,6 +417,8 @@ trait Loggable
|
||||
$log->created_by = auth()->id();
|
||||
$log->filename = $filename;
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
// Explicitly stamp company_id from the item being audited so FMCS scoping works correctly.
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
$log->logaction('audit');
|
||||
|
||||
$params = [
|
||||
@@ -442,6 +504,7 @@ trait Loggable
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
$log->note = $note;
|
||||
$log->created_by = $created_by;
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
$log->logaction('create');
|
||||
$log->save();
|
||||
|
||||
@@ -468,6 +531,7 @@ trait Loggable
|
||||
$log->created_by = auth()->id();
|
||||
$log->note = $note;
|
||||
$log->target_id = null;
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
$log->created_at = date('Y-m-d H:i:s');
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
$log->filename = $filename;
|
||||
|
||||
@@ -56,9 +56,12 @@ trait Searchable
|
||||
$preparedSearch = $this->prepareSearchInput((string) $search);
|
||||
$terms = $preparedSearch['terms'];
|
||||
$filters = $preparedSearch['filters'];
|
||||
$filterOperator = $preparedSearch['filter_operator'];
|
||||
|
||||
if (! empty($filters)) {
|
||||
return $this->applySearchFilters($query, $filters);
|
||||
// Structured advanced-search filters are mutually exclusive with free-text terms.
|
||||
// Once we detect structured payloads, we avoid the broad OR-based free-text path.
|
||||
return $this->applySearchFilters($query, $filters, $filterOperator);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,15 +104,27 @@ trait Searchable
|
||||
return [
|
||||
'terms' => [],
|
||||
'filters' => $parsedFilters,
|
||||
'filter_operator' => $this->resolveStructuredFilterOperator(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'terms' => $this->prepeareSearchTerms($search),
|
||||
'filters' => [],
|
||||
'filter_operator' => 'and',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the structured advanced-search operator from the current request.
|
||||
*/
|
||||
private function resolveStructuredFilterOperator(): string
|
||||
{
|
||||
$operator = strtolower((string) request()->input('filter_operator', 'and'));
|
||||
|
||||
return $operator === 'or' ? 'or' : 'and';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a structured filter payload into scalar string filters.
|
||||
*/
|
||||
@@ -122,6 +137,7 @@ trait Searchable
|
||||
$payload = $search;
|
||||
|
||||
if (str_starts_with($search, 'filter:')) {
|
||||
// Some callers send filter payloads with an explicit "filter:" prefix.
|
||||
$payload = substr($search, 7);
|
||||
} elseif (! (str_starts_with($search, '{') && str_ends_with($search, '}'))) {
|
||||
return null;
|
||||
@@ -147,6 +163,7 @@ trait Searchable
|
||||
$normalizedValue = trim((string) ($value ?? ''));
|
||||
|
||||
if ($normalizedValue === '') {
|
||||
// Ignore empty fields so clearing an input does not create noisy no-op filters.
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -174,83 +191,305 @@ trait Searchable
|
||||
*
|
||||
* @param array<string, string> $filters
|
||||
*/
|
||||
private function applySearchFilters(Builder $query, array $filters): Builder
|
||||
private function applySearchFilters(Builder $query, array $filters, string $filterOperator = 'and'): Builder
|
||||
{
|
||||
if ($filterOperator === 'or') {
|
||||
$query->where(function (Builder $filterQuery) use ($filters) {
|
||||
foreach ($filters as $filterKey => $filterValue) {
|
||||
$this->applySingleSearchFilter($filterQuery, $filterKey, $filterValue, 'or');
|
||||
}
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
foreach ($filters as $filterKey => $filterValue) {
|
||||
$this->applySingleSearchFilter($query, $filterKey, $filterValue);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw filter value for an optional negation, null-check, or exact-match prefix.
|
||||
*
|
||||
* Supported syntax:
|
||||
* - "!flarb" → operator = not_like, value = "flarb"
|
||||
* - "not:flarb" → operator = not_like, value = "flarb"
|
||||
* - "is:null" → operator = is_null, value = "" (reserved token)
|
||||
* - "is:not_null" → operator = is_not_null, value = "" (reserved token)
|
||||
* - "is:flarb" → operator = exact, value = "flarb" (exact equality)
|
||||
* - "is_not:flarb"→ operator = exact_not, value = "flarb" (exact inequality)
|
||||
*
|
||||
* `is:null` and `is:not_null` are checked before the generic `is:` prefix so they always
|
||||
* resolve to their dedicated null-check operators regardless of casing.
|
||||
*
|
||||
* The legacy `negate` boolean is preserved alongside `operator` so that
|
||||
* existing callers that only check `negate` still work correctly.
|
||||
*
|
||||
* @return array{value: string, negate: bool, operator: string}
|
||||
*/
|
||||
private function parseFilterValue(string $raw): array
|
||||
{
|
||||
$lower = strtolower($raw);
|
||||
|
||||
if ($lower === 'is:null') {
|
||||
// Reserved token: interpreted as null-check operator, not exact match string.
|
||||
return ['value' => '', 'negate' => false, 'operator' => 'is_null'];
|
||||
}
|
||||
|
||||
if ($lower === 'is:not_null') {
|
||||
// Reserved token: interpreted as non-null check operator.
|
||||
return ['value' => '', 'negate' => false, 'operator' => 'is_not_null'];
|
||||
}
|
||||
|
||||
if (str_starts_with($lower, 'is:')) {
|
||||
// Generic exact-match prefix. This is checked after reserved is:null/is:not_null tokens.
|
||||
$exactValue = ltrim(substr($raw, 3));
|
||||
|
||||
return ['value' => $exactValue, 'negate' => false, 'operator' => 'exact'];
|
||||
}
|
||||
|
||||
if (str_starts_with($lower, 'is_not:')) {
|
||||
$exactNotValue = ltrim(substr($raw, 7));
|
||||
|
||||
return ['value' => $exactNotValue, 'negate' => true, 'operator' => 'exact_not'];
|
||||
}
|
||||
|
||||
if (str_starts_with($raw, '!')) {
|
||||
return ['value' => substr($raw, 1), 'negate' => true, 'operator' => 'not_like'];
|
||||
}
|
||||
|
||||
if (str_starts_with($lower, 'not:')) {
|
||||
return ['value' => substr($raw, 4), 'negate' => true, 'operator' => 'not_like'];
|
||||
}
|
||||
|
||||
return ['value' => $raw, 'negate' => false, 'operator' => 'like'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single structured filter using the provided boolean operator.
|
||||
*
|
||||
* Negation: if the filter value is prefixed with "!" or "not:", the filter
|
||||
* uses NOT LIKE (for attributes/custom fields) or whereDoesntHave (for
|
||||
* relations), effectively excluding records matching the value.
|
||||
*
|
||||
* For relation filters, negation uses NOT LIKE inside whereHas, meaning
|
||||
* "has a related record where the column does NOT contain the value".
|
||||
* Records with no related record (e.g. unassigned assets) are excluded;
|
||||
* use a plain empty-string filter if you need to match NULLs.
|
||||
*/
|
||||
private function applySingleSearchFilter(Builder $query, string $filterKey, string $filterValue, string $boolean = 'and'): Builder
|
||||
{
|
||||
$parsed = $this->parseFilterValue($filterValue);
|
||||
$value = $parsed['value'];
|
||||
$negate = $parsed['negate'];
|
||||
$operator = $parsed['operator'];
|
||||
|
||||
// IS NULL / IS NOT NULL are handled before value-based filtering,
|
||||
// because there is no meaningful value to pass to LIKE for them.
|
||||
if ($operator === 'is_null' || $operator === 'is_not_null') {
|
||||
return $this->applyNullFilter($query, $filterKey, $operator === 'is_null', $boolean);
|
||||
}
|
||||
|
||||
// Skip gracefully if stripping the prefix leaves an empty value.
|
||||
if ($value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$searchableAttributes = $this->getSearchableAttributes();
|
||||
$searchableCounts = $this->getSearchableCounts();
|
||||
$searchableRelations = $this->getSearchableRelations();
|
||||
$table = $this->getTable();
|
||||
$whereMethod = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
|
||||
$isExactOperator = in_array($operator, ['exact', 'exact_not'], true);
|
||||
$exactComparisonOperator = $operator === 'exact_not' ? '!=' : '=';
|
||||
|
||||
foreach ($filters as $filterKey => $filterValue) {
|
||||
if (in_array($filterKey, $searchableAttributes, true)) {
|
||||
$query->where($table.'.'.$filterKey, 'LIKE', '%'.$filterValue.'%');
|
||||
|
||||
continue;
|
||||
if (in_array($filterKey, $searchableAttributes, true)) {
|
||||
if ($isExactOperator) {
|
||||
$query->{$whereMethod}($table.'.'.$filterKey, $exactComparisonOperator, $value);
|
||||
} else {
|
||||
$query->{$whereMethod}($table.'.'.$filterKey, $likeOperator, '%'.$value.'%');
|
||||
}
|
||||
|
||||
if (in_array($filterKey, $searchableCounts, true)) {
|
||||
$query = $this->applyCountAliasFilter($query, $filterKey, $filterValue);
|
||||
return $query;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
// Handle virtual columns — keys that are not real DB columns but map to a set
|
||||
// of real columns searched via CONCAT (e.g. "name" → first_name + last_name on User).
|
||||
$virtualColumns = $this->getSearchableVirtualColumns();
|
||||
|
||||
// Check if this is a custom field (only for Assets - for *now*).
|
||||
// Only db_column keys (e.g. "_snipeit_cpu_4") are accepted to avoid
|
||||
// collisions with standard attributes or relation filter keys.
|
||||
if ($this instanceof Asset) {
|
||||
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
|
||||
if (array_key_exists($filterKey, $virtualColumns)) {
|
||||
$qualifiedColumns = array_map(
|
||||
fn ($col) => $table.'.'.$col,
|
||||
$virtualColumns[$filterKey]
|
||||
);
|
||||
|
||||
if ($dbColumn !== null) {
|
||||
$query->where($table.'.'.$dbColumn, 'LIKE', '%'.$filterValue.'%');
|
||||
if ($isExactOperator) {
|
||||
// Exact match on the full CONCAT'd value, e.g. "John Smith" matches only
|
||||
// users whose first_name + ' ' + last_name equals exactly "John Smith".
|
||||
$concatSql = $this->buildMultipleColumnSearch($qualifiedColumns);
|
||||
// buildMultipleColumnSearch intentionally returns a fragment ending in "LIKE ?";
|
||||
// for exact matches we rewrite only the operator and keep the same SQL scaffold.
|
||||
$concatSql = str_replace(' LIKE ?', $operator === 'exact_not' ? ' <> ?' : ' = ?', $concatSql);
|
||||
$rawMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
|
||||
$query->{$rawMethod}($concatSql, [$value]);
|
||||
} else {
|
||||
$concatSql = $this->buildMultipleColumnSearch($qualifiedColumns);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
|
||||
|
||||
if ($resolvedRelationKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isAssignedToRelationKey($resolvedRelationKey)) {
|
||||
$query = $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $filterValue);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$relationColumns = (array) $searchableRelations[$resolvedRelationKey];
|
||||
|
||||
$query->whereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $filterValue) {
|
||||
$relationTable = $this->getRelationTable($resolvedRelationKey);
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($relationColumns as $relationColumn) {
|
||||
if (! $firstConditionAdded) {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%');
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$relationQuery->orWhere($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%');
|
||||
if ($negate) {
|
||||
$concatSql = str_replace(' LIKE ?', ' NOT LIKE ?', $concatSql);
|
||||
}
|
||||
|
||||
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
|
||||
$relationQuery->orWhereRaw(
|
||||
$this->buildMultipleColumnSearch(
|
||||
[
|
||||
$rawMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
|
||||
$query->{$rawMethod}($concatSql, ['%'.$value.'%']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
if (in_array($filterKey, $searchableCounts, true)) {
|
||||
return $this->applyCountAliasFilter($query, $filterKey, $value, $boolean, $negate, $isExactOperator);
|
||||
}
|
||||
|
||||
// Check if this is a custom field (only for Assets - for *now*).
|
||||
// Only db_column keys (e.g. "_snipeit_cpu_4") are accepted to avoid
|
||||
// collisions with standard attributes or relation filter keys.
|
||||
if ($this instanceof Asset) {
|
||||
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
|
||||
|
||||
if ($dbColumn !== null) {
|
||||
if ($isExactOperator) {
|
||||
$query->{$whereMethod}($table.'.'.$dbColumn, $exactComparisonOperator, $value);
|
||||
} else {
|
||||
$query->{$whereMethod}($table.'.'.$dbColumn, $likeOperator, '%'.$value.'%');
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
|
||||
|
||||
if ($resolvedRelationKey === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
if ($this->isAssignedToRelationKey($resolvedRelationKey)) {
|
||||
return $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $value, $boolean, $negate, $operator);
|
||||
}
|
||||
|
||||
$relationColumns = $this->getStructuredFilterRelationColumns(
|
||||
filterKey: $filterKey,
|
||||
resolvedRelationKey: $resolvedRelationKey,
|
||||
searchableRelations: $searchableRelations,
|
||||
);
|
||||
|
||||
// For negated relation filters (e.g. location: !dam), include rows with
|
||||
// no related record as well as rows with related records that do not match.
|
||||
// This aligns advanced-search behavior with user expectation for "not X".
|
||||
if ($operator === 'not_like' || $operator === 'exact_not') {
|
||||
$compoundMethod = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$compoundMethod}(function (Builder $compoundQuery) use ($resolvedRelationKey, $relationColumns, $value, $operator): void {
|
||||
// Critical behavior: "not X" on relations should include records with no relation.
|
||||
// Example: location=!dam should include users without a location.
|
||||
$compoundQuery->doesntHave($resolvedRelationKey)
|
||||
->orWhereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value, $operator): void {
|
||||
$relationTable = $this->getRelationTable($resolvedRelationKey);
|
||||
$firstConditionAdded = false;
|
||||
$relationComparisonOperator = $operator === 'exact_not' ? '!=' : 'NOT LIKE';
|
||||
$relationComparisonValue = $operator === 'exact_not' ? $value : '%'.$value.'%';
|
||||
|
||||
foreach ($relationColumns as $relationColumn) {
|
||||
if (! $firstConditionAdded) {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, $relationComparisonOperator, $relationComparisonValue);
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// For negation we AND the NOT LIKE conditions so all columns must not match.
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, $relationComparisonOperator, $relationComparisonValue);
|
||||
}
|
||||
|
||||
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
|
||||
$concatSql = $this->buildMultipleColumnSearch([
|
||||
'users.first_name',
|
||||
'users.last_name',
|
||||
'users.display_name',
|
||||
]
|
||||
),
|
||||
["%{$filterValue}%"]
|
||||
);
|
||||
}
|
||||
]);
|
||||
|
||||
if ($operator === 'exact_not') {
|
||||
$relationQuery->whereRaw(str_replace(' LIKE ?', ' <> ?', $concatSql), [$value]);
|
||||
} else {
|
||||
$relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
$relationMethod = $boolean === 'or' ? 'orWhereHas' : 'whereHas';
|
||||
|
||||
$query->{$relationMethod}($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value, $likeOperator, $operator) {
|
||||
$relationTable = $this->getRelationTable($resolvedRelationKey);
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($relationColumns as $relationColumn) {
|
||||
if (! $firstConditionAdded) {
|
||||
if ($operator === 'exact') {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, '=', $value);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, '!=', $value);
|
||||
} else {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
|
||||
}
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operator === 'exact') {
|
||||
// For exact matches across multiple columns, OR them — any column matching
|
||||
// the exact value is sufficient (e.g. name OR slug).
|
||||
$relationQuery->orWhere($relationTable.'.'.$relationColumn, '=', $value);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
// For exact exclusions we AND the conditions so no column can equal the value.
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, '!=', $value);
|
||||
} elseif ($likeOperator === 'NOT LIKE') {
|
||||
// For negation we AND the NOT LIKE conditions so all columns must not match.
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
|
||||
} else {
|
||||
// For normal LIKE we OR them so any column matching is sufficient.
|
||||
$relationQuery->orWhere($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
|
||||
}
|
||||
}
|
||||
|
||||
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
|
||||
$concatSql = $this->buildMultipleColumnSearch([
|
||||
'users.first_name',
|
||||
'users.last_name',
|
||||
'users.display_name',
|
||||
]);
|
||||
|
||||
if ($operator === 'exact') {
|
||||
$concatSql = str_replace(' LIKE ?', ' = ?', $concatSql);
|
||||
$relationQuery->orWhereRaw($concatSql, [$value]);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
$concatSql = str_replace(' LIKE ?', ' <> ?', $concatSql);
|
||||
$relationQuery->whereRaw($concatSql, [$value]);
|
||||
} elseif ($likeOperator === 'NOT LIKE') {
|
||||
$relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]);
|
||||
} else {
|
||||
$relationQuery->orWhereRaw($concatSql, ["%{$value}%"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
@@ -302,8 +541,13 @@ trait Searchable
|
||||
|
||||
/**
|
||||
* Apply filters for assignees with type-specific searchable columns.
|
||||
*
|
||||
* When $negate is true, NOT LIKE is used inside whereHasMorph, so results
|
||||
* are records that have an assignee whose columns do NOT contain $filterValue.
|
||||
* (Records with no assignee are excluded; they do not satisfy "has an assignee
|
||||
* where column NOT LIKE '%value%'".)
|
||||
*/
|
||||
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue): Builder
|
||||
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue, string $boolean = 'and', bool $negate = false, string $operator = 'like'): Builder
|
||||
{
|
||||
$relationName = $this->resolveAssignedToRelationName();
|
||||
|
||||
@@ -311,10 +555,15 @@ trait Searchable
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereHasMorph(
|
||||
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
|
||||
$isExactOperator = in_array($operator, ['exact', 'exact_not'], true);
|
||||
$exactComparisonOperator = $operator === 'exact_not' ? '!=' : '=';
|
||||
$relationMethod = $boolean === 'or' ? 'orWhereHasMorph' : 'whereHasMorph';
|
||||
|
||||
return $query->{$relationMethod}(
|
||||
$relationName,
|
||||
[User::class, Asset::class, Location::class],
|
||||
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue) {
|
||||
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue, $likeOperator, $negate, $operator, $isExactOperator, $exactComparisonOperator) {
|
||||
$columns = $this->getAssigneeColumnsByType($assigneeType);
|
||||
|
||||
if (empty($columns)) {
|
||||
@@ -326,20 +575,41 @@ trait Searchable
|
||||
|
||||
foreach ($columns as $column) {
|
||||
if (! $firstConditionAdded) {
|
||||
$assigneeQuery->where($table.'.'.$column, 'LIKE', '%'.$filterValue.'%');
|
||||
if ($isExactOperator) {
|
||||
$assigneeQuery->where($table.'.'.$column, $exactComparisonOperator, $filterValue);
|
||||
} else {
|
||||
$assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
|
||||
}
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$assigneeQuery->orWhere($table.'.'.$column, 'LIKE', '%'.$filterValue.'%');
|
||||
// For negation, AND the conditions (all columns must not match).
|
||||
// For normal LIKE, OR them (any column matching is sufficient).
|
||||
if ($operator === 'exact') {
|
||||
$assigneeQuery->orWhere($table.'.'.$column, '=', $filterValue);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
$assigneeQuery->where($table.'.'.$column, '!=', $filterValue);
|
||||
} else {
|
||||
$negate
|
||||
? $assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%')
|
||||
: $assigneeQuery->orWhere($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
|
||||
}
|
||||
}
|
||||
|
||||
if ($assigneeType === User::class) {
|
||||
$assigneeQuery->orWhereRaw(
|
||||
$this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']),
|
||||
["%{$filterValue}%"]
|
||||
);
|
||||
$concatSql = $this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']);
|
||||
|
||||
if ($operator === 'exact') {
|
||||
$assigneeQuery->orWhereRaw(str_replace(' LIKE ?', ' = ?', $concatSql), [$filterValue]);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
$assigneeQuery->whereRaw(str_replace(' LIKE ?', ' <> ?', $concatSql), [$filterValue]);
|
||||
} else {
|
||||
$negate
|
||||
? $assigneeQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$filterValue}%"])
|
||||
: $assigneeQuery->orWhereRaw($concatSql, ["%{$filterValue}%"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -384,13 +654,154 @@ trait Searchable
|
||||
/**
|
||||
* Apply filtering on computed count aliases (for example withCount aliases).
|
||||
*/
|
||||
private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue): Builder
|
||||
private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue, string $boolean = 'and', bool $negate = false, bool $exact = false): Builder
|
||||
{
|
||||
$havingMethod = $boolean === 'or' ? 'orHaving' : 'having';
|
||||
|
||||
if (is_numeric($filterValue)) {
|
||||
return $query->having($countAlias, '=', (int) $filterValue);
|
||||
$operator = $negate ? '!=' : '=';
|
||||
|
||||
return $query->{$havingMethod}($countAlias, $operator, (int) $filterValue);
|
||||
}
|
||||
|
||||
return $query->having($countAlias, 'LIKE', '%'.$filterValue.'%');
|
||||
if ($exact) {
|
||||
$operator = $negate ? '!=' : '=';
|
||||
|
||||
return $query->{$havingMethod}($countAlias, $operator, $filterValue);
|
||||
}
|
||||
|
||||
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
|
||||
|
||||
return $query->{$havingMethod}($countAlias, $likeOperator, '%'.$filterValue.'%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an IS NULL / IS NOT NULL filter for the given filter key.
|
||||
*
|
||||
* Supported targets:
|
||||
*
|
||||
* Direct attributes → WHERE col IS [NOT] NULL
|
||||
*
|
||||
* Virtual columns → IS NULL: all constituent columns must be null
|
||||
* IS NOT NULL: at least one constituent column must not be null
|
||||
*
|
||||
* Relation keys → IS NULL: doesntHave (no related record)
|
||||
* IS NOT NULL: whereHas (has a related record)
|
||||
*
|
||||
* Any unrecognised key is silently ignored.
|
||||
*/
|
||||
private function applyNullFilter(Builder $query, string $filterKey, bool $isNull, string $boolean = 'and'): Builder
|
||||
{
|
||||
$table = $this->getTable();
|
||||
$searchableAttributes = $this->getSearchableAttributes();
|
||||
|
||||
// Custom field db_column key (Asset only).
|
||||
if ($this instanceof Asset) {
|
||||
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
|
||||
|
||||
if ($dbColumn !== null) {
|
||||
$column = $table.'.'.$dbColumn;
|
||||
|
||||
$method = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$method}(function (Builder $subQuery) use ($column, $isNull): void {
|
||||
if ($isNull) {
|
||||
$subQuery->whereNull($column)
|
||||
->orWhere($column, '=', '');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$subQuery->whereNotNull($column)
|
||||
->where($column, '!=', '');
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
// Direct attribute column.
|
||||
if (in_array($filterKey, $searchableAttributes, true)) {
|
||||
$column = $table.'.'.$filterKey;
|
||||
$method = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$method}(function (Builder $subQuery) use ($column, $isNull): void {
|
||||
if ($isNull) {
|
||||
$subQuery->whereNull($column)
|
||||
->orWhere($column, '=', '');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$subQuery->whereNotNull($column)
|
||||
->where($column, '!=', '');
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
// Virtual columns (e.g. 'name' → ['first_name', 'last_name'] on User).
|
||||
$virtualColumns = $this->getSearchableVirtualColumns();
|
||||
|
||||
if (array_key_exists($filterKey, $virtualColumns)) {
|
||||
$qualifiedColumns = array_map(
|
||||
fn ($col) => $table.'.'.$col,
|
||||
$virtualColumns[$filterKey]
|
||||
);
|
||||
|
||||
if ($isNull) {
|
||||
// All constituent columns must be null (= no name at all).
|
||||
foreach ($qualifiedColumns as $col) {
|
||||
$query->whereNull($col);
|
||||
}
|
||||
} else {
|
||||
// At least one constituent column must have a value.
|
||||
$query->where(function (Builder $sub) use ($qualifiedColumns): void {
|
||||
foreach ($qualifiedColumns as $col) {
|
||||
$sub->orWhereNotNull($col);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
// Relation key: no related record = "null", has a related record = "not null".
|
||||
$searchableRelations = $this->getSearchableRelations();
|
||||
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
|
||||
|
||||
if ($resolvedRelationKey !== null && $this->isAssignedToRelationKey($resolvedRelationKey)) {
|
||||
$method = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
// Polymorphic assignment is present only when both columns are set; null matches either side missing.
|
||||
|
||||
if ($isNull) {
|
||||
$query->{$method}(function (Builder $assigneeNullQuery) use ($table): void {
|
||||
$assigneeNullQuery->whereNull($table.'.assigned_to')
|
||||
->orWhereNull($table.'.assigned_type');
|
||||
});
|
||||
} else {
|
||||
$query->{$method}(function (Builder $assigneeNotNullQuery) use ($table): void {
|
||||
$assigneeNotNullQuery->whereNotNull($table.'.assigned_to')
|
||||
->whereNotNull($table.'.assigned_type');
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
if ($resolvedRelationKey !== null) {
|
||||
if ($isNull) {
|
||||
$method = $boolean === 'or' ? 'orDoesntHave' : 'doesntHave';
|
||||
$query->{$method}($resolvedRelationKey);
|
||||
} else {
|
||||
$method = $boolean === 'or' ? 'orWhereHas' : 'whereHas';
|
||||
$query->{$method}($resolvedRelationKey);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,23 +867,32 @@ trait Searchable
|
||||
}
|
||||
|
||||
// Only pull unencrypted fields, since encrypted fields cannot be searched on
|
||||
$customFields = CustomField::where('field_encrypted', 0)->get();
|
||||
$firstConditionAdded = false;
|
||||
$customFields = CustomField::query()
|
||||
->whereNotNull('db_column')
|
||||
->where('field_encrypted', 0)
|
||||
->get(['db_column']);
|
||||
|
||||
foreach ($customFields as $field) {
|
||||
foreach ($terms as $term) {
|
||||
if (! $firstConditionAdded) {
|
||||
$query = $query->where($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = $query->orWhere($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
|
||||
}
|
||||
if ($customFields->isEmpty()) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query;
|
||||
// Group custom-fields so all custom fields behave consistently as OR conditions.
|
||||
return $query->orWhere(function (Builder $customFieldQuery) use ($customFields, $terms): void {
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($customFields as $field) {
|
||||
foreach ($terms as $term) {
|
||||
if (! $firstConditionAdded) {
|
||||
$customFieldQuery->where($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$customFieldQuery->orWhere($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -644,6 +1064,20 @@ trait Searchable
|
||||
return $this->searchableCounts ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get virtual column aliases defined on the model.
|
||||
*
|
||||
* These are filter keys that map to a set of real columns searched via
|
||||
* CONCAT — for example, "name" → ['first_name', 'last_name'] on User,
|
||||
* because "name" is not a real database column on that table.
|
||||
*
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private function getSearchableVirtualColumns(): array
|
||||
{
|
||||
return $this->searchableVirtualColumns ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relation aliases defined on the model.
|
||||
*
|
||||
@@ -663,6 +1097,36 @@ trait Searchable
|
||||
return $this->searchableRelationAliases ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get structured-filter relation columns for a given filter key.
|
||||
*
|
||||
* By default, this uses all configured searchable relation columns for the
|
||||
* resolved relation key. Models can narrow specific advanced-search fields
|
||||
* via $searchableRelationFilterColumns, keyed by the incoming filter key
|
||||
* shown in the UI/API (for example: 'location' => ['name']).
|
||||
*
|
||||
* @param array<string, array<int, string>> $searchableRelations
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getStructuredFilterRelationColumns(string $filterKey, string $resolvedRelationKey, array $searchableRelations): array
|
||||
{
|
||||
$defaultColumns = (array) ($searchableRelations[$resolvedRelationKey] ?? []);
|
||||
|
||||
$overrides = $this->searchableRelationFilterColumns ?? [];
|
||||
|
||||
if (! array_key_exists($filterKey, $overrides)) {
|
||||
return $defaultColumns;
|
||||
}
|
||||
|
||||
$overrideColumns = array_values(array_filter((array) $overrides[$filterKey], 'is_string'));
|
||||
|
||||
// Keep only columns that are actually searchable on the resolved relation,
|
||||
// so model-level overrides cannot accidentally reference unknown columns.
|
||||
$validColumns = array_values(array_intersect($overrideColumns, $defaultColumns));
|
||||
|
||||
return $validColumns !== [] ? $validColumns : $defaultColumns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the table name of a relation.
|
||||
*
|
||||
@@ -720,6 +1184,9 @@ trait Searchable
|
||||
*/
|
||||
private function buildMultipleColumnSearch(array $columns): string
|
||||
{
|
||||
// This method deliberately returns only an SQL fragment ending with "LIKE ?"
|
||||
// so callers can reuse it and swap operators (NOT LIKE / =) without duplicating
|
||||
// driver-specific CONCAT syntax.
|
||||
$mappedColumns = collect($columns)->map(fn ($column) => DB::getTablePrefix().$column)->toArray();
|
||||
|
||||
$driver = config('database.connections.'.config('database.default').'.driver');
|
||||
|
||||
+258
-1
@@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\Access\Authorizable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
@@ -180,6 +181,44 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
'manages_locations_count',
|
||||
];
|
||||
|
||||
/**
|
||||
* Virtual column aliases that map a single filter key to a set of real columns
|
||||
* searched via CONCAT (SQL) so that, for example, filtering by "name" searches
|
||||
* across both first_name and last_name together.
|
||||
*
|
||||
* Because "name" is not a real column on the users table we cannot add it to
|
||||
* $searchableAttributes; this map bridges that gap for structured filter queries.
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
protected $searchableVirtualColumns = [
|
||||
'name' => ['first_name', 'last_name'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps filter/API keys to the actual Eloquent relation names used in
|
||||
* $searchableRelations. The User model uses "userloc" as its location
|
||||
* relation name (to avoid a collision with the framework's own "location"
|
||||
* magic), but every consumer — UI and API alike — sends the key "location".
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $searchableRelationAliases = [
|
||||
'location' => 'userloc',
|
||||
];
|
||||
|
||||
/**
|
||||
* Narrow structured-filter relation columns for specific UI/API filter keys.
|
||||
*
|
||||
* The advanced-search "location" field represents the location name, so
|
||||
* structured filters should target only userloc.name (not address/city/etc).
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
protected $searchableRelationFilterColumns = [
|
||||
'location' => ['name'],
|
||||
];
|
||||
|
||||
/**
|
||||
* This sets the name property on the user. It's not a real field in the database
|
||||
* (since we use first_name and last_name), but the Laravel mailable method
|
||||
@@ -207,13 +246,57 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
{
|
||||
static::forceDeleted(function (User $user) {
|
||||
CheckoutRequest::where(['user_id' => $user->id])->forceDelete();
|
||||
$user->purgeAssociatedPassportTokens();
|
||||
});
|
||||
|
||||
static::softDeleted(function (User $user) {
|
||||
CheckoutRequest::where(['user_id' => $user->id])->delete();
|
||||
$user->revokeAssociatedPassportTokens();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all Passport access/refresh tokens associated with this user.
|
||||
*/
|
||||
private function revokeAssociatedPassportTokens(): void
|
||||
{
|
||||
$accessTokenIds = DB::table('oauth_access_tokens')
|
||||
->where('user_id', $this->id)
|
||||
->pluck('id');
|
||||
|
||||
if ($accessTokenIds->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('oauth_access_tokens')
|
||||
->whereIn('id', $accessTokenIds)
|
||||
->update(['revoked' => true]);
|
||||
|
||||
DB::table('oauth_refresh_tokens')
|
||||
->whereIn('access_token_id', $accessTokenIds)
|
||||
->update(['revoked' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete all Passport access/refresh tokens associated with this user.
|
||||
*/
|
||||
private function purgeAssociatedPassportTokens(): void
|
||||
{
|
||||
$accessTokenIds = DB::table('oauth_access_tokens')
|
||||
->where('user_id', $this->id)
|
||||
->pluck('id');
|
||||
|
||||
if ($accessTokenIds->isNotEmpty()) {
|
||||
DB::table('oauth_refresh_tokens')
|
||||
->whereIn('access_token_id', $accessTokenIds)
|
||||
->delete();
|
||||
}
|
||||
|
||||
DB::table('oauth_access_tokens')
|
||||
->where('user_id', $this->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* This overrides the SnipeModel displayName accessor to return the full name if display_name is not set
|
||||
*
|
||||
@@ -259,6 +342,120 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of effective user permissions grouped by permission section.
|
||||
*
|
||||
* Includes explicit denials from user or group permissions so the UI can
|
||||
* show both allowed and denied entries.
|
||||
*
|
||||
* This is kind of duplicative from the other permission-checking methods, but it allows us to build a
|
||||
* list of permissions for display purposes without having to do a lot of super-confusing and
|
||||
* redundant checks in the UI layer.
|
||||
*
|
||||
* This will likely go away once we refactor the permissions to be in a database table instead of the
|
||||
* stupiud config file.
|
||||
*/
|
||||
public function getEffectivePermissionsBySection(): array
|
||||
{
|
||||
$displayablePermissions = collect(config('permissions'))
|
||||
->map(static fn (array $permissions): array => array_values(array_filter($permissions, static fn (array $permission): bool => ($permission['display'] ?? false) === true)))
|
||||
->all();
|
||||
|
||||
$configuredPermissions = collect($displayablePermissions)
|
||||
->flatMap(static function (array $permissions, string $section) {
|
||||
return collect($permissions)->map(static function (array $permission) use ($section): array {
|
||||
return [
|
||||
'section' => $section,
|
||||
'permission' => $permission['permission'],
|
||||
];
|
||||
});
|
||||
})
|
||||
->unique('permission')
|
||||
->values();
|
||||
|
||||
$directPermissions = $this->decodePermissions();
|
||||
$directPermissions = is_array($directPermissions) ? $directPermissions : [];
|
||||
|
||||
$groupGrantsByPermission = [];
|
||||
$groupDenialsByPermission = [];
|
||||
foreach ($this->groups as $group) {
|
||||
$groupPermissions = $group->decodePermissions();
|
||||
if (! is_array($groupPermissions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($groupPermissions as $permissionKey => $permissionValue) {
|
||||
if ((int) $permissionValue === 1) {
|
||||
$groupGrantsByPermission[$permissionKey][] = $group->name;
|
||||
} elseif ((int) $permissionValue === -1) {
|
||||
$groupDenialsByPermission[$permissionKey][] = $group->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveBySection = [];
|
||||
foreach ($configuredPermissions as $permissionConfig) {
|
||||
$permissionKey = $permissionConfig['permission'];
|
||||
$directPermissionValue = (int) ($directPermissions[$permissionKey] ?? 0);
|
||||
$isAllowed = $this->hasAccess($permissionKey);
|
||||
$isDenied = ($directPermissionValue === -1) || ((count($groupDenialsByPermission[$permissionKey] ?? []) > 0) && ! $isAllowed);
|
||||
|
||||
if (! $isAllowed && ! $isDenied) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $isDenied ? 'denied' : 'allowed';
|
||||
$source = 'group';
|
||||
$sourceGroups = $isDenied
|
||||
? ($groupDenialsByPermission[$permissionKey] ?? [])
|
||||
: ($groupGrantsByPermission[$permissionKey] ?? []);
|
||||
|
||||
if ($isDenied && $directPermissionValue === -1) {
|
||||
$source = 'individual';
|
||||
$sourceGroups = [];
|
||||
} elseif ($this->isSuperUser()) {
|
||||
$source = 'superuser';
|
||||
$sourceGroups = [];
|
||||
} elseif (! $isDenied && $directPermissionValue === 1) {
|
||||
$source = 'individual';
|
||||
$sourceGroups = [];
|
||||
}
|
||||
|
||||
$effectiveBySection[$permissionConfig['section']][] = [
|
||||
'permission' => $permissionKey,
|
||||
'status' => $status,
|
||||
'source' => $source,
|
||||
'groups' => array_values(array_unique($sourceGroups)),
|
||||
'source_label' => $this->buildPermissionSourceLabel(
|
||||
status: $status,
|
||||
source: $source,
|
||||
sourceGroups: $sourceGroups
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $effectiveBySection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact source label for a permission entry.
|
||||
*/
|
||||
private function buildPermissionSourceLabel(string $status, string $source, array $sourceGroups = []): string
|
||||
{
|
||||
$statusLabel = $status === 'denied' ? 'Denied' : 'Allowed';
|
||||
$sourceLabel = match ($source) {
|
||||
'individual' => 'Individual',
|
||||
'superuser' => 'Superuser',
|
||||
default => 'Group',
|
||||
};
|
||||
|
||||
if ($sourceGroups === []) {
|
||||
return $statusLabel.' ('.$sourceLabel.')';
|
||||
}
|
||||
|
||||
return $statusLabel.' ('.$sourceLabel.'): '.implode(', ', array_values(array_unique($sourceGroups)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internally check the user permission for the given section
|
||||
*
|
||||
@@ -528,6 +725,10 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
{
|
||||
return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at');
|
||||
}
|
||||
public function directLicenses()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the user -> reportTemplates relationship
|
||||
@@ -697,6 +898,22 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assigned items that still have a pending acceptance for this user.
|
||||
*/
|
||||
public function getAssignedItemsWithPendingAcceptance(): Collection
|
||||
{
|
||||
return CheckoutAcceptance::query()
|
||||
->forUser($this)
|
||||
->pending()
|
||||
->with('checkoutable')
|
||||
->get()
|
||||
->map(fn (CheckoutAcceptance $acceptance) => $acceptance->checkoutable)
|
||||
->filter()
|
||||
->unique(fn ($item) => $item::class.':'.$item->getKey())
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the user -> eula relationship
|
||||
*
|
||||
@@ -1176,7 +1393,47 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
->orwhereRaw('CONCAT(users.first_name," ",users.last_name) LIKE \''.$search.'%\'');
|
||||
|
||||
}
|
||||
|
||||
public function scopeWithInventoryRelations($query, int $id)
|
||||
{
|
||||
return $query->where('id', $id)
|
||||
->with([
|
||||
'assets.log' => fn ($query) => $query->withTrashed()
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $id)
|
||||
->where('action_type', 'accepted'),
|
||||
'assets.defaultLoc',
|
||||
'assets.location',
|
||||
'assets.model.category',
|
||||
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $id)
|
||||
->where('action_type', 'accepted'),
|
||||
'assets.assignedAssets.assignedTo',
|
||||
'assets.assignedAssets.defaultLoc',
|
||||
'assets.assignedAssets.location',
|
||||
'assets.assignedAssets.model.category',
|
||||
'assets.components.category',
|
||||
'assets.licenses',
|
||||
'assets.licenses.category',
|
||||
'assets.assignedAccessories',
|
||||
'assets.assignedAccessories.accessory.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',
|
||||
'directLicenses.category',
|
||||
'licenses.category',
|
||||
])
|
||||
->withTrashed();
|
||||
}
|
||||
/**
|
||||
* Get all direct and indirect subordinates for this user.
|
||||
*
|
||||
|
||||
@@ -33,6 +33,7 @@ class AcceptanceItemAcceptedNotification extends Notification
|
||||
$this->file = $params['file'] ?? null;
|
||||
$this->qty = $params['qty'] ?? null;
|
||||
$this->note = $params['note'] ?? null;
|
||||
$this->custom_fields = $params['custom_fields'] ?? [];
|
||||
|
||||
}
|
||||
|
||||
@@ -76,6 +77,7 @@ class AcceptanceItemAcceptedNotification extends Notification
|
||||
'assigned_to' => $this->assigned_to,
|
||||
'company_name' => $this->company_name,
|
||||
'qty' => $this->qty,
|
||||
'custom_fields' => $this->custom_fields,
|
||||
'intro_text' => trans('mail.acceptance_accepted_greeting', ['user' => $this->assigned_to, 'item' => $this->item_name]),
|
||||
])
|
||||
->subject('✅ '.trans('mail.acceptance_accepted', ['user' => $this->assigned_to, 'item' => $this->item_name]))
|
||||
|
||||
@@ -34,6 +34,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
|
||||
$this->settings = Setting::getSettings();
|
||||
$this->file = $params['file'] ?? null;
|
||||
$this->qty = $params['qty'] ?? null;
|
||||
$this->custom_fields = $params['custom_fields'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
|
||||
'assigned_to' => $this->assigned_to,
|
||||
'company_name' => $this->company_name,
|
||||
'qty' => $this->qty,
|
||||
'custom_fields' => $this->custom_fields,
|
||||
'intro_text' => trans_choice('mail.acceptance_asset_accepted_to_user', $this->qty, ['qty' => $this->qty, 'site_name' => $this->settings->site_name]),
|
||||
])
|
||||
->attach($pdf_path)
|
||||
|
||||
@@ -13,8 +13,25 @@ class MaintenanceObserver
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updated(Maintenance $maintenance)
|
||||
public function updating(Maintenance $maintenance)
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach ($maintenance->getRawOriginal() as $key => $value) {
|
||||
if (array_key_exists($key, $maintenance->getAttributes())
|
||||
&& $maintenance->getRawOriginal()[$key] != $maintenance->getAttributes()[$key]
|
||||
) {
|
||||
$changed[$key] = [
|
||||
'old' => $maintenance->getRawOriginal()[$key],
|
||||
'new' => $maintenance->getAttributes()[$key],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($changed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = Maintenance::class;
|
||||
$logAction->item_id = $maintenance->id;
|
||||
@@ -23,6 +40,7 @@ class MaintenanceObserver
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->action_date = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->log_meta = json_encode($changed);
|
||||
if ($maintenance->imported) {
|
||||
$logAction->setActionSource('importer');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Maintenance;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* Policy for Asset Maintenances.
|
||||
*
|
||||
* A user may view or create maintenances on an asset if they have permission
|
||||
* to edit that asset. All other standard CRUD operations fall back to the
|
||||
* assets.edit permission, consistent with the rest of the application.
|
||||
*/
|
||||
final class MaintenancePolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Superusers and admins are handled globally in AuthServiceProvider::boot().
|
||||
* Company-scoping is enforced at the model level via CompanyableChildTrait.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine whether the user can list maintenances.
|
||||
* Requires asset edit permission (no specific asset to check against).
|
||||
*/
|
||||
public function index(User $user): bool
|
||||
{
|
||||
return $user->hasAccess('assets.view');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view a specific maintenance record.
|
||||
* Allowed if the user can edit the associated asset.
|
||||
*/
|
||||
public function view(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return Gate::allows('update', $maintenance->asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create a maintenance record.
|
||||
* When checking against the class (no instance), fall back to assets.edit.
|
||||
* When an asset instance is provided via context, check update on that asset.
|
||||
*/
|
||||
public function create(User $user, ?Asset $asset = null): bool
|
||||
{
|
||||
if ($asset instanceof Asset) {
|
||||
return Gate::allows('update', $asset);
|
||||
}
|
||||
|
||||
return $user->hasAccess('assets.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update a maintenance record.
|
||||
* Allowed if the user can edit the associated asset.
|
||||
*/
|
||||
public function update(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return Gate::allows('update', $maintenance->asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete a maintenance record.
|
||||
* Allowed if the user can edit the associated asset and the record is not soft-deleted.
|
||||
*/
|
||||
public function delete(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return empty($maintenance->deleted_at)
|
||||
&& Gate::allows('update', $maintenance->asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can upload or manage files attached to a maintenance record.
|
||||
* Allowed if the user can edit the associated asset.
|
||||
*/
|
||||
public function files(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return Gate::allows('update', $maintenance->asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view history for a maintenance record.
|
||||
* Allowed when the user can view the maintenance itself, or has global activity view permission.
|
||||
*/
|
||||
public function history(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return Gate::allows('view', $maintenance->asset)
|
||||
|| Gate::allows('view', $maintenance)
|
||||
|| $user->hasAccess('activity.view');
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,18 @@ class CategoryPresenter extends Presenter
|
||||
], [
|
||||
'field' => 'has_eula',
|
||||
'searchable' => false,
|
||||
'sortable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('admin/categories/table.eula_text'),
|
||||
'visible' => false,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'use_default_eula',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('admin/settings/general.default_eula_text'),
|
||||
'visible' => false,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
], [
|
||||
'field' => 'checkin_email',
|
||||
'searchable' => false,
|
||||
@@ -78,13 +86,6 @@ class CategoryPresenter extends Presenter
|
||||
'title' => trans('admin/categories/table.require_acceptance'),
|
||||
'visible' => true,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
], [
|
||||
'field' => 'use_default_eula',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('admin/categories/general.use_default_eula_column'),
|
||||
'visible' => true,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
], [
|
||||
'field' => 'tag_color',
|
||||
'searchable' => true,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Presenters;
|
||||
|
||||
use App\Models\CustomField;
|
||||
|
||||
final class CustomFieldPresenter
|
||||
{
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function visibilityIconsArray(CustomField $field): array
|
||||
{
|
||||
$icons = [];
|
||||
|
||||
if ($field->display_checkout) {
|
||||
$label = e(trans('admin/custom_fields/general.display_checkout'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-rotate-left text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->display_checkin) {
|
||||
$label = e(trans('admin/custom_fields/general.display_checkin'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-rotate-right text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->display_audit) {
|
||||
$label = e(trans('admin/custom_fields/general.display_audit'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-clipboard-check text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->display_in_user_view) {
|
||||
$label = e(trans('admin/custom_fields/general.display_in_user_view_table'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-user text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->show_in_listview) {
|
||||
$label = e(trans('admin/custom_fields/general.show_in_listview_short'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-list text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->show_in_email) {
|
||||
$label = e(trans('admin/custom_fields/general.show_in_email_short'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-envelope text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->show_in_requestable_list) {
|
||||
$label = e(trans('admin/custom_fields/general.show_in_requestable_list_short'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-bell-concierge text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
return $icons;
|
||||
}
|
||||
|
||||
public static function visibilityIcons(CustomField $field): string
|
||||
{
|
||||
return implode(' ', self::visibilityIconsArray($field));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ class LicensePresenter extends Presenter
|
||||
{
|
||||
$layout = [
|
||||
[
|
||||
'field' => 'checkbox',
|
||||
'checkbox' => true,
|
||||
'formatter' => 'checkboxEnabledFormatter',
|
||||
'titleTooltip' => trans('general.select_all_none'),
|
||||
'printIgnore' => true,
|
||||
'class' => 'hidden-print',
|
||||
], [
|
||||
'field' => 'id',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
@@ -115,7 +122,7 @@ class LicensePresenter extends Presenter
|
||||
'searchable' => false,
|
||||
'sortable' => false,
|
||||
'switchable' => true,
|
||||
'title' => '% ' . trans('general.remaining'),
|
||||
'title' => '% '.trans('general.remaining'),
|
||||
'visible' => true,
|
||||
'formatter' => 'progressBarFormatter',
|
||||
], [
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Models\Department;
|
||||
use App\Models\Depreciation;
|
||||
use App\Models\License;
|
||||
use App\Models\Location;
|
||||
use App\Models\Maintenance;
|
||||
use App\Models\Manufacturer;
|
||||
use App\Models\PredefinedKit;
|
||||
use App\Models\Statuslabel;
|
||||
@@ -33,6 +34,7 @@ use App\Policies\DepartmentPolicy;
|
||||
use App\Policies\DepreciationPolicy;
|
||||
use App\Policies\LicensePolicy;
|
||||
use App\Policies\LocationPolicy;
|
||||
use App\Policies\MaintenancePolicy;
|
||||
use App\Policies\ManufacturerPolicy;
|
||||
use App\Policies\PredefinedKitPolicy;
|
||||
use App\Policies\StatuslabelPolicy;
|
||||
@@ -68,6 +70,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
Depreciation::class => DepreciationPolicy::class,
|
||||
License::class => LicensePolicy::class,
|
||||
Location::class => LocationPolicy::class,
|
||||
Maintenance::class => MaintenancePolicy::class,
|
||||
PredefinedKit::class => PredefinedKitPolicy::class,
|
||||
Statuslabel::class => StatuslabelPolicy::class,
|
||||
Supplier::class => SupplierPolicy::class,
|
||||
|
||||
@@ -342,14 +342,16 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
->push(trans('admin/locations/table.clone'), route('locations.create'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('locations.show', fn (Trail $trail, Location $location) => $trail->parent('locations.index', route('locations.index'))
|
||||
->push($location->name, route('locations.show', $location))
|
||||
);
|
||||
Breadcrumbs::for('locations.show', function (Trail $trail, Location $location) {
|
||||
$trail->parent('locations.index', route('locations.index'));
|
||||
$this->pushLocationHierarchy($trail, $location);
|
||||
});
|
||||
|
||||
Breadcrumbs::for('locations.edit', fn (Trail $trail, Location $location) => $trail->parent('locations.index', route('locations.index'))
|
||||
->push($location->display_name, route('locations.show', $location))
|
||||
->push(trans('general.update'))
|
||||
);
|
||||
Breadcrumbs::for('locations.edit', function (Trail $trail, Location $location) {
|
||||
$trail->parent('locations.index', route('locations.index'));
|
||||
$this->pushLocationHierarchy($trail, $location);
|
||||
$trail->push(trans('general.update'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Maintenances Breadcrumbs
|
||||
@@ -510,4 +512,22 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Append parent -> child location breadcrumbs recursively for a location.
|
||||
*/
|
||||
private function pushLocationHierarchy(Trail $trail, Location $location): void
|
||||
{
|
||||
$ancestorChain = [];
|
||||
$cursor = $location;
|
||||
|
||||
while ($cursor !== null) {
|
||||
array_unshift($ancestorChain, $cursor);
|
||||
$cursor = $cursor->parent;
|
||||
}
|
||||
|
||||
foreach ($ancestorChain as $node) {
|
||||
$trail->push($node->name, route('locations.show', $node));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class NumericEncrypted implements ValidationRule
|
||||
try {
|
||||
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
|
||||
$decrypted = Crypt::decrypt($value);
|
||||
if (!$this->validateNumeric($attributeName, $decrypted) && !is_null($decrypted)) {
|
||||
if (!$this->validateNumeric($attributeName, $decrypted, []) && !is_null($decrypted)) {
|
||||
$fail(trans('validation.numeric', ['attribute' => $attributeName]));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ class Label implements View
|
||||
$logo = Storage::disk('public')->path('companies/'.e($asset->company->image));
|
||||
} elseif (! empty($settings->label_logo)) {
|
||||
// Use the general site label logo, if available
|
||||
$logo = Storage::disk('public')->path('/'.e($settings->label_logo));
|
||||
$logo = Storage::disk('public')->path('/'.e(basename($settings->label_logo)));
|
||||
} elseif (! empty($asset->is_label_preview)) {
|
||||
$logo = public_path('img/label-preview-logo.png');
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user