Merge remote-tracking branch 'origin/develop'

This commit is contained in:
snipe
2026-05-21 15:06:36 +01:00
13 changed files with 376 additions and 103 deletions
+7
View File
@@ -19,6 +19,7 @@ use Illuminate\Validation\ValidationException;
use Intervention\Image\Exception\NotSupportedException;
use JsonException;
use League\OAuth2\Server\Exception\OAuthServerException;
use Livewire\Exceptions\PublicPropertyNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
@@ -41,6 +42,7 @@ class Handler extends ExceptionHandler
JsonException::class,
SCIMException::class, // these generally don't need to be reported
InvalidFormatException::class,
PublicPropertyNotFoundException::class,
];
/**
@@ -71,6 +73,11 @@ class Handler extends ExceptionHandler
public function render($request, Throwable $e)
{
// Livewire tried to set a property that doesn't exist (e.g. stale browser state sending a bare "0" as a property name)
if ($e instanceof PublicPropertyNotFoundException) {
return response()->json(['message' => $e->getMessage()], 422);
}
// CSRF token mismatch error
if ($e instanceof TokenMismatchException) {
return redirect()->back()->with('error', trans('general.token_expired'));
+3 -2
View File
@@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Helpers\Helper;
use App\Models\Actionlog;
use App\Models\Asset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
@@ -17,6 +16,9 @@ class ActionlogController extends Controller
{
$filename = basename((string) $filename);
$actionlog = Actionlog::where('accept_signature', $filename)->with('item')->firstOrFail();
$this->authorize('view', $actionlog->item);
// PHP doesn't let you handle file not found errors well with
// file_get_contents, so we set the error reporting for just this class
error_reporting(0);
@@ -29,7 +31,6 @@ class ActionlogController extends Controller
return redirect()->away(Storage::disk($disk)->temporaryUrl($file, now()->addMinutes(5)));
default:
$this->authorize('view', Asset::class);
$file = config('app.private_uploads').'/signatures/'.$filename;
$filetype = Helper::checkUploadIsImage($file);
@@ -171,8 +171,6 @@ class BulkUsersController extends Controller
->conditionallyAddItem('company_id')
->conditionallyAddItem('locale')
->conditionallyAddItem('remote')
->conditionallyAddItem('ldap_import')
->conditionallyAddItem('activated')
->conditionallyAddItem('display_name')
->conditionallyAddItem('start_date')
->conditionallyAddItem('end_date')
@@ -235,11 +233,21 @@ class BulkUsersController extends Controller
->update(['location_id' => $this->update_array['location_id']]);
}
// Only sync groups if groups were selected
if ($request->filled('groups')) {
foreach ($users as $user) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
// Fields that require canEditAuthFields (non-admins cannot touch admins/superusers,
// admins cannot touch superusers) must be applied per-user, not via mass update.
foreach ($users as $user) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$authFieldUpdate = [];
if ($request->filled('activated')) {
$authFieldUpdate['activated'] = $request->input('activated');
}
if ($request->filled('ldap_import')) {
$authFieldUpdate['ldap_import'] = $request->input('ldap_import');
}
if (! empty($authFieldUpdate)) {
$user->update($authFieldUpdate);
}
if ($request->filled('groups')) {
$user->groups()->sync($request->input('groups'));
}
}
@@ -398,7 +406,7 @@ class BulkUsersController extends Controller
*/
public function merge(Request $request)
{
$this->authorize('update', User::class);
$this->authorize('delete', User::class);
if (config('app.lock_passwords')) {
return redirect()->route('users.index')->with('error', trans('general.feature_disabled'));
@@ -419,6 +427,10 @@ class BulkUsersController extends Controller
// Walk users
foreach ($users_to_merge as $user_to_merge) {
if (! auth()->user()->can('canEditAuthFields', $user_to_merge) || ! auth()->user()->can('editableOnDemo')) {
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
}
foreach ($user_to_merge->assets as $asset) {
Log::debug('Updating asset: '.$asset->asset_tag.' to '.$merge_into_user->id);
$asset->assigned_to = $request->input('merge_into_id');
+6
View File
@@ -720,6 +720,12 @@ class Importer extends Component
$this->message_type = 'danger';
}
public function process(): void
{
$this->message = trans('general.token_expired');
$this->message_type = 'danger';
}
public function clearMessage()
{
$this->message = null;
+1 -3
View File
@@ -85,9 +85,7 @@
"nunomaduro/phpinsights": "^2.11",
"php-mock/php-mock-phpunit": "^2.10",
"phpunit/phpunit": "^11.0",
"squizlabs/php_codesniffer": "^3.5",
"symfony/css-selector": "^4.4",
"symfony/dom-crawler": "^4.4"
"squizlabs/php_codesniffer": "^3.5"
},
"extra": {
"laravel": {
Generated
+12 -83
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2d0e79dc07fa3389dba6fd8005bde5aa",
"content-hash": "ed0655f6c3c75cda1939dfc27b492029",
"packages": [
{
"name": "alek13/slack",
@@ -8485,21 +8485,20 @@
},
{
"name": "symfony/css-selector",
"version": "v4.4.44",
"version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "bd0a6737e48de45b4b0b7b6fc98c78404ddceaed"
"reference": "b75663ed96cf4756e28e3105476f220f92886cc4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/bd0a6737e48de45b4b0b7b6fc98c78404ddceaed",
"reference": "bd0a6737e48de45b4b0b7b6fc98c78404ddceaed",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4",
"reference": "b75663ed96cf4756e28e3105476f220f92886cc4",
"shasum": ""
},
"require": {
"php": ">=7.1.3",
"symfony/polyfill-php80": "^1.16"
"php": ">=8.2"
},
"type": "library",
"autoload": {
@@ -8531,7 +8530,7 @@
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/css-selector/tree/v4.4.44"
"source": "https://github.com/symfony/css-selector/tree/v7.4.9"
},
"funding": [
{
@@ -8542,12 +8541,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2022-06-27T13:16:42+00:00"
"time": "2026-04-18T13:18:21+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -16290,80 +16293,6 @@
],
"time": "2026-05-05T15:33:14+00:00"
},
{
"name": "symfony/dom-crawler",
"version": "v4.4.45",
"source": {
"type": "git",
"url": "https://github.com/symfony/dom-crawler.git",
"reference": "4b8daf6c56801e6d664224261cb100b73edc78a5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4b8daf6c56801e6d664224261cb100b73edc78a5",
"reference": "4b8daf6c56801e6d664224261cb100b73edc78a5",
"shasum": ""
},
"require": {
"php": ">=7.1.3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php80": "^1.16"
},
"conflict": {
"masterminds/html5": "<2.6"
},
"require-dev": {
"masterminds/html5": "^2.6",
"symfony/css-selector": "^3.4|^4.0|^5.0"
},
"suggest": {
"symfony/css-selector": ""
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\DomCrawler\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases DOM navigation for HTML and XML documents",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dom-crawler/tree/v4.4.45"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2022-08-03T12:57:57+00:00"
},
{
"name": "symfony/http-client",
"version": "v7.4.9",
+5
View File
@@ -618,6 +618,11 @@ document.addEventListener('livewire:init', () => {
console.error("For data-livewire-component, you probably want to use $this->getId() or {{ $this->getId() }}, as appropriate")
return false
}
// PHP property names cannot start with a digit — skip bare numeric names (e.g. "0") that would cause a 500
if (/^\d+$/.test(event.target.name)) {
console.error("Livewire select2: name attribute '" + event.target.name + "' is not a valid Livewire property name — skipping")
return false
}
Livewire.find(target.data('livewire-component')).set(event.target.name, this.options[this.selectedIndex].value)
});
+1
View File
@@ -679,6 +679,7 @@ return [
'user_managed_passwords' => 'Password Management',
'user_managed_passwords_disallow' => 'Disallow users from managing their own passwords',
'user_managed_passwords_allow' => 'Allow users to manage their own passwords',
'user_managed_passwords_bulk_help' => 'This setting will only be applied to users that you are able to edit authentication settings on.',
'from' => 'From',
'by' => 'By',
'by_user' => 'By',
@@ -159,6 +159,7 @@
<input type="radio" name="ldap_import" id="ldap_import" value="1" aria-label="ldap_import">
{{ trans('general.user_managed_passwords_disallow') }}
</label>
<p class="help-block">{{ trans('general.user_managed_passwords_bulk_help') }}</p>
</div>
</div> <!--/form-group-->
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\ActionLogs;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\License;
use App\Models\User;
use Tests\TestCase;
class DisplaySigTest extends TestCase
{
public function test_requires_authentication(): void
{
$actionlog = Actionlog::factory()->acceptedSignature()->create();
$this->get(route('log.signature.view', ['filename' => $actionlog->accept_signature]))
->assertRedirect(route('login'));
}
public function test_nonexistent_filename_redirects_with_error(): void
{
$this->actingAs(User::factory()->superuser()->create())
->get(route('log.signature.view', ['filename' => 'does-not-exist.png']))
->assertRedirect(route('home'));
}
public function test_user_without_view_permission_cannot_view_asset_signature(): void
{
$actionlog = Actionlog::factory()->acceptedSignature()->create();
$this->actingAs(User::factory()->create())
->get(route('log.signature.view', ['filename' => $actionlog->accept_signature]))
->assertForbidden();
}
public function test_user_with_asset_view_permission_can_view_asset_signature(): void
{
$asset = Asset::factory()->create();
$actionlog = Actionlog::factory()->create([
'action_type' => 'accepted',
'item_id' => $asset->id,
'item_type' => Asset::class,
'accept_signature' => 'test-asset-sig-'.uniqid().'.png',
]);
$this->actingAs(User::factory()->viewAssets()->create())
->get(route('log.signature.view', ['filename' => $actionlog->accept_signature]))
->assertOk();
}
public function test_user_with_asset_view_permission_cannot_view_license_signature(): void
{
$license = License::factory()->create();
$actionlog = Actionlog::factory()->create([
'action_type' => 'accepted',
'item_id' => $license->id,
'item_type' => License::class,
'accept_signature' => 'test-license-sig-'.uniqid().'.png',
]);
$this->actingAs(User::factory()->viewAssets()->create())
->get(route('log.signature.view', ['filename' => $actionlog->accept_signature]))
->assertForbidden();
}
public function test_user_with_license_view_permission_can_view_license_signature(): void
{
$license = License::factory()->create();
$actionlog = Actionlog::factory()->create([
'action_type' => 'accepted',
'item_id' => $license->id,
'item_type' => License::class,
'accept_signature' => 'test-license-sig-'.uniqid().'.png',
]);
$this->actingAs(User::factory()->viewLicenses()->create())
->get(route('log.signature.view', ['filename' => $actionlog->accept_signature]))
->assertOk();
}
}
@@ -0,0 +1,123 @@
<?php
namespace Tests\Feature\Users\Ui\BulkActions;
use App\Models\User;
use Tests\TestCase;
class BulkEditUsersTest extends TestCase
{
public function test_requires_correct_permission()
{
$this->actingAs(User::factory()->create())
->post(route('users/bulkeditsave'), [
'ids' => [User::factory()->create()->id],
])
->assertForbidden();
}
public function test_non_admin_cannot_deactivate_admin_via_bulk_edit()
{
$actor = User::factory()->editUsers()->create();
$admin = User::factory()->admin()->create(['activated' => 1]);
$this->actingAs($actor)
->post(route('users/bulkeditsave'), [
'ids' => [$admin->id],
'activated' => '0',
])
->assertRedirect(route('users.index'));
$this->assertEquals(1, $admin->fresh()->activated);
}
public function test_non_admin_cannot_deactivate_superuser_via_bulk_edit()
{
$actor = User::factory()->editUsers()->create();
$superuser = User::factory()->superuser()->create(['activated' => 1]);
$this->actingAs($actor)
->post(route('users/bulkeditsave'), [
'ids' => [$superuser->id],
'activated' => '0',
])
->assertRedirect(route('users.index'));
$this->assertEquals(1, $superuser->fresh()->activated);
}
public function test_admin_cannot_deactivate_superuser_via_bulk_edit()
{
$admin = User::factory()->admin()->create();
$superuser = User::factory()->superuser()->create(['activated' => 1]);
$this->actingAs($admin)
->post(route('users/bulkeditsave'), [
'ids' => [$superuser->id],
'activated' => '0',
])
->assertRedirect(route('users.index'));
$this->assertEquals(1, $superuser->fresh()->activated);
}
public function test_non_admin_can_deactivate_regular_user_via_bulk_edit()
{
$actor = User::factory()->editUsers()->create();
$target = User::factory()->create(['activated' => 1]);
$this->actingAs($actor)
->post(route('users/bulkeditsave'), [
'ids' => [$target->id],
'activated' => '0',
])
->assertRedirect(route('users.index'));
$this->assertEquals(0, $target->fresh()->activated);
}
public function test_admin_can_deactivate_regular_user_via_bulk_edit()
{
$admin = User::factory()->admin()->create();
$target = User::factory()->create(['activated' => 1]);
$this->actingAs($admin)
->post(route('users/bulkeditsave'), [
'ids' => [$target->id],
'activated' => '0',
])
->assertRedirect(route('users.index'));
$this->assertEquals(0, $target->fresh()->activated);
}
public function test_non_admin_cannot_set_ldap_import_on_admin_via_bulk_edit()
{
$actor = User::factory()->editUsers()->create();
$admin = User::factory()->admin()->create(['ldap_import' => 0]);
$this->actingAs($actor)
->post(route('users/bulkeditsave'), [
'ids' => [$admin->id],
'ldap_import' => '1',
])
->assertRedirect(route('users.index'));
$this->assertEquals(0, $admin->fresh()->ldap_import);
}
public function test_non_auth_fields_are_still_updated_for_admin_targets()
{
$actor = User::factory()->editUsers()->create();
$admin = User::factory()->admin()->create(['city' => 'Springfield']);
$this->actingAs($actor)
->post(route('users/bulkeditsave'), [
'ids' => [$admin->id],
'city' => 'Shelbyville',
])
->assertRedirect(route('users.index'));
$this->assertEquals('Shelbyville', $admin->fresh()->city);
}
}
@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature\Users\Ui\BulkActions;
use App\Models\Asset;
use App\Models\User;
use Tests\TestCase;
class BulkMergeUsersTest extends TestCase
{
public function test_requires_delete_permission()
{
$target = User::factory()->create();
$to_merge = User::factory()->create();
$this->actingAs(User::factory()->editUsers()->create())
->post(route('users.merge.save'), [
'ids_to_merge' => [$to_merge->id],
'merge_into_id' => $target->id,
])
->assertForbidden();
$this->assertNotSoftDeleted($to_merge);
}
public function test_non_admin_cannot_merge_admin_into_self()
{
$actor = User::factory()->deleteUsers()->create();
$admin = User::factory()->admin()->create();
$this->actingAs($actor)
->post(route('users.merge.save'), [
'ids_to_merge' => [$admin->id],
'merge_into_id' => $actor->id,
])
->assertRedirect(route('users.index'))
->assertSessionHas('error');
$this->assertNotSoftDeleted($admin);
}
public function test_non_admin_cannot_merge_superuser_into_self()
{
$actor = User::factory()->deleteUsers()->create();
$superuser = User::factory()->superuser()->create();
$this->actingAs($actor)
->post(route('users.merge.save'), [
'ids_to_merge' => [$superuser->id],
'merge_into_id' => $actor->id,
])
->assertRedirect(route('users.index'))
->assertSessionHas('error');
$this->assertNotSoftDeleted($superuser);
}
public function test_admin_cannot_merge_superuser_into_self()
{
$admin = User::factory()->admin()->create();
$superuser = User::factory()->superuser()->create();
$this->actingAs($admin)
->post(route('users.merge.save'), [
'ids_to_merge' => [$superuser->id],
'merge_into_id' => $admin->id,
])
->assertRedirect(route('users.index'))
->assertSessionHas('error');
$this->assertNotSoftDeleted($superuser);
}
public function test_assets_are_transferred_and_source_user_is_deleted_on_merge()
{
$admin = User::factory()->admin()->create();
$source = User::factory()->create();
$target = User::factory()->create();
$asset = Asset::factory()->assignedToUser($source)->create();
$this->actingAs($admin)
->post(route('users.merge.save'), [
'ids_to_merge' => [$source->id],
'merge_into_id' => $target->id,
])
->assertRedirect(route('users.index'))
->assertSessionHas('success');
$this->assertSoftDeleted($source);
$this->assertEquals($target->id, $asset->fresh()->assigned_to);
}
public function test_merge_does_not_transfer_assets_when_source_is_protected()
{
$actor = User::factory()->deleteUsers()->create();
$admin = User::factory()->admin()->create();
$asset = Asset::factory()->assignedToUser($admin)->create();
$this->actingAs($actor)
->post(route('users.merge.save'), [
'ids_to_merge' => [$admin->id],
'merge_into_id' => $actor->id,
])
->assertRedirect(route('users.index'))
->assertSessionHas('error');
$this->assertEquals($admin->id, $asset->fresh()->assigned_to);
}
}
+7 -7
View File
@@ -22,7 +22,7 @@ class MergeUsersTest extends TestCase
Asset::factory()->count(3)->assignedToUser($user2)->create();
Asset::factory()->count(3)->assignedToUser($user_to_merge_into)->create();
$response = $this->actingAs(User::factory()->editUsers()->viewUsers()->create())
$response = $this->actingAs(User::factory()->deleteUsers()->viewUsers()->create())
->post(route('users.merge.save', $user1->id),
[
'ids_to_merge' => [$user1->id, $user2->id],
@@ -50,7 +50,7 @@ class MergeUsersTest extends TestCase
$this->assertEquals(3, $user_to_merge_into->refresh()->licenses->count());
$response = $this->actingAs(User::factory()->editUsers()->viewUsers()->create())
$response = $this->actingAs(User::factory()->deleteUsers()->viewUsers()->create())
->post(route('users.merge.save', $user1->id),
[
'ids_to_merge' => [$user1->id, $user2->id],
@@ -78,7 +78,7 @@ class MergeUsersTest extends TestCase
$this->assertEquals(3, $user_to_merge_into->refresh()->accessories->count());
$response = $this->actingAs(User::factory()->editUsers()->viewUsers()->create())
$response = $this->actingAs(User::factory()->deleteUsers()->viewUsers()->create())
->post(route('users.merge.save', $user1->id),
[
'ids_to_merge' => [$user1->id, $user2->id],
@@ -106,7 +106,7 @@ class MergeUsersTest extends TestCase
$this->assertEquals(3, $user_to_merge_into->refresh()->consumables->count());
$response = $this->actingAs(User::factory()->editUsers()->viewUsers()->create())
$response = $this->actingAs(User::factory()->deleteUsers()->viewUsers()->create())
->post(route('users.merge.save', $user1->id),
[
'ids_to_merge' => [$user1->id, $user2->id],
@@ -134,7 +134,7 @@ class MergeUsersTest extends TestCase
$this->assertEquals(3, $user_to_merge_into->refresh()->uploads->count());
$response = $this->actingAs(User::factory()->editUsers()->viewUsers()->create())
$response = $this->actingAs(User::factory()->deleteUsers()->viewUsers()->create())
->post(route('users.merge.save', $user1->id),
[
'ids_to_merge' => [$user1->id, $user2->id],
@@ -162,7 +162,7 @@ class MergeUsersTest extends TestCase
$this->assertEquals(3, $user_to_merge_into->refresh()->acceptances->count());
$response = $this->actingAs(User::factory()->editUsers()->viewUsers()->create())
$response = $this->actingAs(User::factory()->deleteUsers()->viewUsers()->create())
->post(route('users.merge.save', $user1->id),
[
'ids_to_merge' => [$user1->id, $user2->id],
@@ -190,7 +190,7 @@ class MergeUsersTest extends TestCase
$this->assertEquals(3, $user_to_merge_into->refresh()->userlog->count());
$response = $this->actingAs(User::factory()->editUsers()->viewUsers()->create())
$response = $this->actingAs(User::factory()->deleteUsers()->viewUsers()->create())
->post(route('users.merge.save', $user1->id),
[
'ids_to_merge' => [$user1->id, $user2->id],