Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d895b3d33 | |||
| 1a5e5567ec | |||
| bfd155ec2e | |||
| 236d5330e5 | |||
| c95306cd3e | |||
| 018fa3e8a0 | |||
| 0103aaffbf | |||
| abc6386a71 | |||
| c37556bc04 | |||
| d5843f367d | |||
| 49e4fe7081 | |||
| 5fca750402 | |||
| f5db2e0056 | |||
| 26f0312094 | |||
| ca797c4b4e | |||
| a013bc0660 | |||
| 5696481ba9 | |||
| 79183b5ce5 | |||
| 72d33bd371 | |||
| 752b5f7a5f | |||
| 0d04bcc7f6 | |||
| 8fec8b83e7 | |||
| 7ab18d0e8f | |||
| d35e510468 | |||
| 30f9e89d4a | |||
| b6f781c39e | |||
| 8c6bcab2eb | |||
| d38253523b | |||
| 7dfd5bd8a5 | |||
| e0cc7bba4f | |||
| 88714badec |
@@ -108,7 +108,6 @@ class UsersController extends Controller
|
||||
'last_name',
|
||||
'first_name',
|
||||
'display_name',
|
||||
'email',
|
||||
'jobtitle',
|
||||
'username',
|
||||
'employee_num',
|
||||
@@ -125,13 +124,6 @@ class UsersController extends Controller
|
||||
'accessories_count',
|
||||
'manages_users_count',
|
||||
'manages_locations_count',
|
||||
'phone',
|
||||
'mobile',
|
||||
'address',
|
||||
'city',
|
||||
'state',
|
||||
'country',
|
||||
'zip',
|
||||
'id',
|
||||
'ldap_import',
|
||||
'two_factor_optin',
|
||||
@@ -141,7 +133,6 @@ class UsersController extends Controller
|
||||
'start_date',
|
||||
'end_date',
|
||||
'autoassign_licenses',
|
||||
'website',
|
||||
'locale',
|
||||
'notes',
|
||||
'employee_num',
|
||||
@@ -158,19 +149,19 @@ class UsersController extends Controller
|
||||
|
||||
];
|
||||
|
||||
$filter = [];
|
||||
|
||||
if ($request->filled('filter')) {
|
||||
$filter = json_decode($request->input('filter'), true);
|
||||
|
||||
if (is_null($filter)) {
|
||||
$filter = [];
|
||||
}
|
||||
|
||||
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
|
||||
return in_array($key, $allowed_columns);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
// Do not even request these fields if the requesting user cannot manage user contact info
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
array_push($allowed_columns,
|
||||
'address',
|
||||
'city',
|
||||
'country',
|
||||
'email',
|
||||
'mobile',
|
||||
'phone',
|
||||
'state',
|
||||
'website',
|
||||
'zip',
|
||||
);
|
||||
}
|
||||
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
@@ -194,12 +185,41 @@ class UsersController extends Controller
|
||||
$users = $users->where('users.company_id', '=', $request->input('company_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('phone')) {
|
||||
$users = $users->where('users.phone', '=', $request->input('phone'));
|
||||
}
|
||||
// Check that the user can view contact info
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
|
||||
if ($request->filled('address')) {
|
||||
$users = $users->where('users.address', '=', $request->input('address'));
|
||||
}
|
||||
|
||||
if ($request->filled('phone')) {
|
||||
$users = $users->where('users.phone', '=', $request->input('phone'));
|
||||
}
|
||||
|
||||
if ($request->filled('mobile')) {
|
||||
$users = $users->where('users.mobile', '=', $request->input('mobile'));
|
||||
}
|
||||
|
||||
if ($request->filled('email')) {
|
||||
$users = $users->where('users.email', '=', $request->input('email'));
|
||||
}
|
||||
|
||||
if ($request->filled('state')) {
|
||||
$users = $users->where('users.state', '=', $request->input('state'));
|
||||
}
|
||||
|
||||
if ($request->filled('country')) {
|
||||
$users = $users->where('users.country', '=', $request->input('country'));
|
||||
}
|
||||
|
||||
if ($request->filled('website')) {
|
||||
$users = $users->where('users.website', '=', $request->input('website'));
|
||||
}
|
||||
|
||||
if ($request->filled('zip')) {
|
||||
$users = $users->where('users.zip', '=', $request->input('zip'));
|
||||
}
|
||||
|
||||
if ($request->filled('mobile')) {
|
||||
$users = $users->where('users.mobile', '=', $request->input('mobile'));
|
||||
}
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
@@ -210,10 +230,6 @@ class UsersController extends Controller
|
||||
$users = $users->where('users.created_by', '=', $request->input('created_by'));
|
||||
}
|
||||
|
||||
if ($request->filled('email')) {
|
||||
$users = $users->where('users.email', '=', $request->input('email'));
|
||||
}
|
||||
|
||||
if ($request->filled('username')) {
|
||||
$users = $users->where('users.username', '=', $request->input('username'));
|
||||
}
|
||||
@@ -234,22 +250,6 @@ class UsersController extends Controller
|
||||
$users = $users->where('users.employee_num', '=', $request->input('employee_num'));
|
||||
}
|
||||
|
||||
if ($request->filled('state')) {
|
||||
$users = $users->where('users.state', '=', $request->input('state'));
|
||||
}
|
||||
|
||||
if ($request->filled('country')) {
|
||||
$users = $users->where('users.country', '=', $request->input('country'));
|
||||
}
|
||||
|
||||
if ($request->filled('website')) {
|
||||
$users = $users->where('users.website', '=', $request->input('website'));
|
||||
}
|
||||
|
||||
if ($request->filled('zip')) {
|
||||
$users = $users->where('users.zip', '=', $request->input('zip'));
|
||||
}
|
||||
|
||||
if ($request->filled('group_id')) {
|
||||
$users = $users->ByGroup($request->input('group_id'));
|
||||
}
|
||||
@@ -380,27 +380,33 @@ class UsersController extends Controller
|
||||
*/
|
||||
public function selectlist(Request $request): array
|
||||
{
|
||||
$users = User::select(
|
||||
[
|
||||
'users.id',
|
||||
'users.username',
|
||||
'users.employee_num',
|
||||
'users.first_name',
|
||||
'users.last_name',
|
||||
'users.display_name',
|
||||
'users.gravatar',
|
||||
'users.avatar',
|
||||
'users.email',
|
||||
]
|
||||
)->where('show_in_list', '=', '1');
|
||||
$select_array = [
|
||||
'users.id',
|
||||
'users.username',
|
||||
'users.employee_num',
|
||||
'users.first_name',
|
||||
'users.last_name',
|
||||
'users.display_name',
|
||||
'users.gravatar',
|
||||
'users.avatar',
|
||||
];
|
||||
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
array_push($select_array, 'users.email');
|
||||
}
|
||||
|
||||
$users = User::select($select_array)->where('show_in_list', '=', '1');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$users = $users->where(function ($query) use ($request) {
|
||||
$query->SimpleNameSearch($request->input('search'))
|
||||
->orWhere('username', 'LIKE', '%'.$request->input('search').'%')
|
||||
->orWhere('display_name', 'LIKE', '%'.$request->input('search').'%')
|
||||
->orWhere('email', 'LIKE', '%'.$request->input('search').'%')
|
||||
->orWhere('employee_num', 'LIKE', '%'.$request->input('search').'%');
|
||||
|
||||
$query->SimpleNameSearch($request->input('search'));
|
||||
|
||||
// Check that the requesting user can search against the email field
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
$query->orWhere('users.email', 'LIKE', '%'.$request->input('search').'%');
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -527,19 +533,40 @@ class UsersController extends Controller
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$authenticatedUser = auth()->user();
|
||||
|
||||
/**
|
||||
* This is a janky hack to prevent people from changing admin demo user data on the public demo.
|
||||
* The $ids 1 and 2 are special since they are seeded as superadmins in the demo seeder.
|
||||
* Thanks, jerks. You are why we can't have nice things. - snipe
|
||||
*/
|
||||
if ((($user->id == 1) || ($user->id == 2)) && (config('app.lock_passwords'))) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Permission denied. You cannot update user information via API on the demo.'));
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Permission denied. You cannot update superuser information via API on the demo.'));
|
||||
}
|
||||
|
||||
// Pull out sensitive fields that require extra permission
|
||||
$user->fill($request->except(['password', 'username', 'email', 'activated', 'permissions', 'activation_code', 'remember_token', 'two_factor_secret', 'two_factor_enrolled', 'two_factor_optin']));
|
||||
$user->fill($request->except([
|
||||
'activated',
|
||||
'activation_code',
|
||||
'created_by',
|
||||
'email',
|
||||
'password',
|
||||
'permissions',
|
||||
'remember_token',
|
||||
'two_factor_enrolled',
|
||||
'two_factor_optin',
|
||||
'two_factor_secret',
|
||||
'username',
|
||||
]));
|
||||
|
||||
if (auth()->user()->cannot('manageContactInfo', User::class)) {
|
||||
$request->request->remove('address');
|
||||
$request->request->remove('city');
|
||||
$request->request->remove('country');
|
||||
$request->request->remove('mobile');
|
||||
$request->request->remove('phone');
|
||||
$request->request->remove('state');
|
||||
$request->request->remove('website');
|
||||
$request->request->remove('zip');
|
||||
}
|
||||
|
||||
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
|
||||
|
||||
@@ -567,7 +594,7 @@ class UsersController extends Controller
|
||||
// This is going to update the whole thing, not just what was passed.
|
||||
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
|
||||
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
|
||||
authenticatedUser: $authenticatedUser,
|
||||
authenticatedUser: auth()->user(),
|
||||
originalPermissions: NormalizePermissionsPayloadAction::run($user->decodePermissions()),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Requests\CustomAssetReportRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Mail\CheckoutAccessoryMail;
|
||||
use App\Mail\CheckoutAssetMail;
|
||||
use App\Mail\CheckoutComponentMail;
|
||||
@@ -25,6 +26,7 @@ use App\Models\LicenseSeat;
|
||||
use App\Models\Maintenance;
|
||||
use App\Models\ReportTemplate;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
@@ -251,6 +253,7 @@ class ReportsController extends Controller
|
||||
|
||||
$response = new StreamedResponse(function () {
|
||||
Log::debug('Starting streamed response');
|
||||
$actionlogsTransformer = new ActionlogsTransformer;
|
||||
|
||||
// Open output stream
|
||||
$handle = fopen('php://output', 'w');
|
||||
@@ -281,7 +284,7 @@ class ReportsController extends Controller
|
||||
|
||||
$actionlogs = Actionlog::with('item', 'user', 'target', 'location', 'adminuser')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->chunk(500, function ($actionlogs) use ($handle) {
|
||||
->chunk(500, function ($actionlogs) use ($handle, $actionlogsTransformer) {
|
||||
$executionTime = microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'];
|
||||
Log::debug('Walking results: '.$executionTime);
|
||||
$count = 0;
|
||||
@@ -300,6 +303,8 @@ class ReportsController extends Controller
|
||||
$item_name = '';
|
||||
}
|
||||
|
||||
$transformedLogMeta = $actionlogsTransformer->transformActionlog($actionlog)['log_meta'] ?? null;
|
||||
|
||||
$row = [
|
||||
$actionlog->created_at,
|
||||
($actionlog->adminuser) ? $actionlog->adminuser->display_name : '',
|
||||
@@ -314,7 +319,7 @@ class ReportsController extends Controller
|
||||
$actionlog->remote_ip,
|
||||
$actionlog->user_agent,
|
||||
$actionlog->action_source,
|
||||
$actionlog->log_meta,
|
||||
is_array($transformedLogMeta) ? $actionlogsTransformer->formatChangedMetaForCsv($transformedLogMeta) : null,
|
||||
];
|
||||
fputcsv($handle, $row);
|
||||
}
|
||||
@@ -445,6 +450,13 @@ class ReportsController extends Controller
|
||||
ini_set('max_execution_time', env('REPORT_TIME_LIMIT', 12000)); // 12000 seconds = 200 minutes
|
||||
$this->authorize('reports.view');
|
||||
|
||||
// Prevent users without contact permission from requesting sensitive user columns in CSV exports.
|
||||
if (! auth()->user()?->can('manageContactInfo', User::class)) {
|
||||
foreach (['email', 'phone', 'user_address', 'user_city', 'user_state', 'user_country', 'user_zip'] as $field) {
|
||||
$request->request->remove($field);
|
||||
}
|
||||
}
|
||||
|
||||
$this->disableDebugbar();
|
||||
|
||||
$customfields = CustomField::get();
|
||||
|
||||
@@ -176,9 +176,16 @@ class BulkUsersController extends Controller
|
||||
->conditionallyAddItem('display_name')
|
||||
->conditionallyAddItem('start_date')
|
||||
->conditionallyAddItem('end_date')
|
||||
->conditionallyAddItem('city')
|
||||
->conditionallyAddItem('autoassign_licenses');
|
||||
|
||||
// Check that the user can manage contact info for users
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
$this->conditionallyAddItem('city')
|
||||
->conditionallyAddItem('state')
|
||||
->conditionallyAddItem('country')
|
||||
->conditionallyAddItem('zip');
|
||||
}
|
||||
|
||||
// If the manager_id is one of the users being updated, generate a warning.
|
||||
if (array_search($request->input('manager_id'), $user_raw_array)) {
|
||||
$manager_conflict = true;
|
||||
|
||||
@@ -21,6 +21,7 @@ use App\Notifications\CurrentInventory;
|
||||
use App\Notifications\WelcomeNotification;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -105,8 +106,20 @@ class UsersController extends Controller
|
||||
|
||||
$authenticatedUser = auth()->user();
|
||||
$user = new User;
|
||||
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
$user->address = $request->input('address', null);
|
||||
$user->city = $request->input('city', null);
|
||||
$user->country = $request->input('country', null);
|
||||
$user->email = trim($request->input('email'));
|
||||
$user->mobile = $request->input('mobile');
|
||||
$user->phone = $request->input('phone');
|
||||
$user->state = $request->input('state', null);
|
||||
$user->website = $request->input('website', null);
|
||||
$user->zip = $request->input('zip', null);
|
||||
}
|
||||
|
||||
// Username, email, and password need to be handled specially because the need to respect config values on an edit.
|
||||
$user->email = trim($request->input('email'));
|
||||
$user->username = trim($request->input('username'));
|
||||
$user->display_name = $request->input('display_name');
|
||||
if ($request->filled('password')) {
|
||||
@@ -118,20 +131,12 @@ class UsersController extends Controller
|
||||
$user->employee_num = $request->input('employee_num');
|
||||
$user->activated = $request->input('activated', 0);
|
||||
$user->jobtitle = $request->input('jobtitle');
|
||||
$user->phone = $request->input('phone');
|
||||
$user->mobile = $request->input('mobile');
|
||||
$user->location_id = $request->input('location_id', null);
|
||||
$user->department_id = $request->input('department_id', null);
|
||||
$user->company_id = Company::getIdForUser($request->input('company_id', null));
|
||||
$user->manager_id = $request->input('manager_id', null);
|
||||
$user->notes = $request->input('notes');
|
||||
$user->address = $request->input('address', null);
|
||||
$user->city = $request->input('city', null);
|
||||
$user->state = $request->input('state', null);
|
||||
$user->country = $request->input('country', null);
|
||||
$user->zip = $request->input('zip', null);
|
||||
$user->remote = $request->input('remote', 0);
|
||||
$user->website = $request->input('website', null);
|
||||
$user->created_by = auth()->id();
|
||||
$user->start_date = $request->input('start_date', null);
|
||||
$user->end_date = $request->input('end_date', null);
|
||||
@@ -242,8 +247,6 @@ class UsersController extends Controller
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$authenticatedUser = auth()->user();
|
||||
|
||||
// This is a janky hack to prevent people from changing admin demo user data on the public demo.
|
||||
// The $ids 1 and 2 are special since they are seeded as superadmins in the demo seeder.
|
||||
// Thanks, jerks. You are why we can't have nice things. - snipe
|
||||
@@ -264,6 +267,18 @@ class UsersController extends Controller
|
||||
|
||||
// Update the user fields
|
||||
|
||||
// Check that the user can update contact info, but skip email, since we have to do an additional check below for that
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
$user->address = $request->input('address', null);
|
||||
$user->city = $request->input('city', null);
|
||||
$user->country = $request->input('country', null);
|
||||
$user->mobile = $request->input('mobile');
|
||||
$user->phone = $request->input('phone');
|
||||
$user->state = $request->input('state', null);
|
||||
$user->website = $request->input('website', null);
|
||||
$user->zip = $request->input('zip', null);
|
||||
}
|
||||
|
||||
$user->first_name = $request->input('first_name');
|
||||
$user->last_name = $request->input('last_name');
|
||||
$user->display_name = $request->input('display_name');
|
||||
@@ -271,21 +286,13 @@ class UsersController extends Controller
|
||||
$user->locale = $request->input('locale');
|
||||
$user->employee_num = $request->input('employee_num');
|
||||
$user->jobtitle = $request->input('jobtitle', null);
|
||||
$user->phone = $request->input('phone');
|
||||
$user->mobile = $request->input('mobile');
|
||||
$user->location_id = $request->input('location_id', null);
|
||||
$user->company_id = Company::getIdForUser($request->input('company_id', null));
|
||||
$user->manager_id = $request->input('manager_id', null);
|
||||
$user->notes = $request->input('notes');
|
||||
$user->department_id = $request->input('department_id', null);
|
||||
$user->address = $request->input('address', null);
|
||||
$user->city = $request->input('city', null);
|
||||
$user->state = $request->input('state', null);
|
||||
$user->country = $request->input('country', null);
|
||||
$user->zip = $request->input('zip', null);
|
||||
$user->remote = $request->input('remote', 0);
|
||||
$user->vip = $request->input('vip', 0);
|
||||
$user->website = $request->input('website', null);
|
||||
$user->start_date = $request->input('start_date', null);
|
||||
$user->end_date = $request->input('end_date', null);
|
||||
$user->autoassign_licenses = $request->input('autoassign_licenses', 0);
|
||||
@@ -312,7 +319,7 @@ class UsersController extends Controller
|
||||
|
||||
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
|
||||
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
|
||||
authenticatedUser: $authenticatedUser,
|
||||
authenticatedUser: auth()->user(),
|
||||
originalPermissions: $orig_permissions_array,
|
||||
));
|
||||
|
||||
@@ -487,7 +494,11 @@ class UsersController extends Controller
|
||||
// Blank out some fields
|
||||
$user->first_name = '';
|
||||
$user->last_name = '';
|
||||
$user->email = substr($user->email, ($pos = strpos($user->email, '@')) !== false ? $pos : 0);
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
$user->email = substr($user->email, ($pos = strpos($user->email, '@')) !== false ? $pos : 0);
|
||||
} else {
|
||||
$user->email = '';
|
||||
}
|
||||
$user->id = null;
|
||||
$user->username = null;
|
||||
$user->avatar = null;
|
||||
@@ -533,6 +544,65 @@ class UsersController extends Controller
|
||||
// Open output stream
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
$headers = [
|
||||
// strtolower to prevent Excel from trying to open it as a SYLK file
|
||||
strtolower(trans('general.id')),
|
||||
trans('admin/companies/table.title'),
|
||||
trans('admin/users/table.title'),
|
||||
trans('general.employee_number'),
|
||||
trans('admin/users/table.first_name'),
|
||||
trans('admin/users/table.last_name'),
|
||||
trans('admin/users/table.name'),
|
||||
trans('admin/users/table.username'),
|
||||
trans('admin/users/table.display_name'),
|
||||
];
|
||||
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
array_push($headers,
|
||||
trans('admin/users/table.email'),
|
||||
trans('admin/users/table.phone'),
|
||||
trans('admin/users/table.mobile'),
|
||||
trans('general.address'),
|
||||
trans('general.city'),
|
||||
trans('general.state'),
|
||||
trans('general.country'),
|
||||
trans('general.zip'),
|
||||
trans('general.website'));
|
||||
|
||||
}
|
||||
|
||||
array_push($headers,
|
||||
trans('admin/users/table.manager'),
|
||||
trans('admin/users/table.location'),
|
||||
trans('general.department'),
|
||||
trans('admin/users/general.department_manager'),
|
||||
trans('general.assets'),
|
||||
trans('general.licenses'),
|
||||
trans('general.accessories'),
|
||||
trans('general.consumables'),
|
||||
trans('admin/users/table.managed_users'),
|
||||
trans('admin/users/table.managed_locations'),
|
||||
trans('general.groups'),
|
||||
trans('general.permissions'),
|
||||
trans('general.notes'),
|
||||
trans('admin/users/table.activated'),
|
||||
trans('admin/settings/general.ldap_enabled'),
|
||||
trans('admin/users/general.two_factor_enrolled'),
|
||||
trans('admin/users/general.two_factor_active'),
|
||||
trans('general.autoassign_licenses'),
|
||||
trans('admin/users/general.remote'),
|
||||
trans('admin/users/general.vip_label'),
|
||||
trans('general.language'),
|
||||
trans('general.start_date'),
|
||||
trans('general.end_date'),
|
||||
trans('general.last_login'),
|
||||
trans('general.updated_at'),
|
||||
trans('general.created_at'),
|
||||
trans('general.created_by'),
|
||||
);
|
||||
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
$users = User::with(
|
||||
'assets',
|
||||
'accessories',
|
||||
@@ -543,34 +613,17 @@ class UsersController extends Controller
|
||||
'groups',
|
||||
'userloc',
|
||||
'company'
|
||||
)->orderBy('created_at', 'DESC')
|
||||
)->withCount([
|
||||
'assets as assets_count' => function (Builder $query) {
|
||||
$query->withoutTrashed();
|
||||
},
|
||||
'licenses as licenses_count',
|
||||
'accessories as accessories_count',
|
||||
'consumables as consumables_count',
|
||||
'managesUsers as manages_users_count',
|
||||
'managedLocations as manages_locations_count',
|
||||
])->orderBy('created_at', 'DESC')
|
||||
->chunk(500, function ($users) use ($handle) {
|
||||
$headers = [
|
||||
// strtolower to prevent Excel from trying to open it as a SYLK file
|
||||
strtolower(trans('general.id')),
|
||||
trans('admin/companies/table.title'),
|
||||
trans('admin/users/table.title'),
|
||||
trans('general.employee_number'),
|
||||
trans('admin/users/table.first_name'),
|
||||
trans('admin/users/table.last_name'),
|
||||
trans('admin/users/table.name'),
|
||||
trans('admin/users/table.username'),
|
||||
trans('admin/users/table.email'),
|
||||
trans('admin/users/table.manager'),
|
||||
trans('admin/users/table.location'),
|
||||
trans('general.department'),
|
||||
trans('general.assets'),
|
||||
trans('general.licenses'),
|
||||
trans('general.accessories'),
|
||||
trans('general.consumables'),
|
||||
trans('general.groups'),
|
||||
trans('general.permissions'),
|
||||
trans('general.notes'),
|
||||
trans('admin/users/table.activated'),
|
||||
trans('general.created_at'),
|
||||
];
|
||||
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user_groups = '';
|
||||
@@ -579,8 +632,6 @@ class UsersController extends Controller
|
||||
$user_groups .= $user_group->name.', ';
|
||||
}
|
||||
|
||||
$permissionstring = '';
|
||||
|
||||
if ($user->isSuperUser()) {
|
||||
$permissionstring = trans('general.superuser');
|
||||
} elseif ($user->hasAccess('admin')) {
|
||||
@@ -599,20 +650,53 @@ class UsersController extends Controller
|
||||
$user->last_name,
|
||||
$user->display_name,
|
||||
$user->username,
|
||||
$user->email,
|
||||
$user->getRawOriginal('display_name'),
|
||||
];
|
||||
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
|
||||
array_push($values,
|
||||
$user->email,
|
||||
$user->phone,
|
||||
$user->mobile,
|
||||
$user->address,
|
||||
$user->city,
|
||||
$user->state,
|
||||
$user->country,
|
||||
$user->zip,
|
||||
$user->website,
|
||||
);
|
||||
}
|
||||
|
||||
array_push($values,
|
||||
($user->manager) ? $user->manager->display_name : '',
|
||||
($user->userloc) ? $user->userloc->name : '',
|
||||
($user->department) ? $user->department->name : '',
|
||||
$user->assets->count(),
|
||||
$user->licenses->count(),
|
||||
$user->accessories->count(),
|
||||
$user->consumables->count(),
|
||||
(($user->department) && ($user->department->manager)) ? $user->department->manager->display_name : '',
|
||||
$user->assets_count,
|
||||
$user->licenses_count,
|
||||
$user->accessories_count,
|
||||
$user->consumables_count,
|
||||
$user->manages_users_count,
|
||||
$user->manages_locations_count,
|
||||
$user_groups,
|
||||
$permissionstring,
|
||||
$user->notes,
|
||||
($user->activated == '1') ? trans('general.yes') : trans('general.no'),
|
||||
($user->ldap_import == '1') ? trans('general.yes') : trans('general.no'),
|
||||
($user->two_factor_active_and_enrolled()) ? trans('general.yes') : trans('general.no'),
|
||||
($user->two_factor_active()) ? trans('general.yes') : trans('general.no'),
|
||||
($user->autoassign_licenses == '1') ? trans('general.yes') : trans('general.no'),
|
||||
($user->remote == '1') ? trans('general.yes') : trans('general.no'),
|
||||
($user->vip == '1') ? trans('general.yes') : trans('general.no'),
|
||||
$user->locale,
|
||||
$user->start_date,
|
||||
$user->end_date,
|
||||
$user->last_login,
|
||||
$user->updated_at,
|
||||
$user->created_at,
|
||||
];
|
||||
$user->createdBy?->display_name,
|
||||
);
|
||||
|
||||
fputcsv($handle, $values);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\Location;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Statuslabel;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@@ -22,6 +23,18 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ActionlogsTransformer
|
||||
{
|
||||
private const CONTACT_INFO_META_FIELDS = [
|
||||
'email',
|
||||
'phone',
|
||||
'mobile',
|
||||
'address',
|
||||
'city',
|
||||
'state',
|
||||
'zip',
|
||||
'country',
|
||||
'website',
|
||||
];
|
||||
|
||||
public function transformActionlogs(Collection $actionlogs, $total)
|
||||
{
|
||||
$array = [];
|
||||
@@ -52,6 +65,8 @@ class ActionlogsTransformer
|
||||
|
||||
public function transformActionlog(Actionlog $actionlog, $settings = null)
|
||||
{
|
||||
$settings ??= Setting::getSettings();
|
||||
$auditInterval = $this->normalizeAuditInterval(data_get($settings, 'audit_interval'));
|
||||
|
||||
$icon = $actionlog->present()->icon();
|
||||
|
||||
@@ -75,6 +90,10 @@ class ActionlogsTransformer
|
||||
|
||||
foreach ($meta_array as $fieldname => $fieldata) {
|
||||
|
||||
if ($this->shouldHideSensitiveMetaField($fieldname)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clean_meta[$fieldname]['old'] = $this->clean_field($fieldata->old);
|
||||
$clean_meta[$fieldname]['new'] = $this->clean_field($fieldata->new);
|
||||
|
||||
@@ -163,8 +182,8 @@ class ActionlogsTransformer
|
||||
] : null,
|
||||
'created_at' => Helper::getFormattedDateObject($actionlog->created_at, 'datetime'),
|
||||
'updated_at' => Helper::getFormattedDateObject($actionlog->updated_at, 'datetime'),
|
||||
'next_audit_date' => ($actionlog->itemType() == 'asset') ? Helper::getFormattedDateObject($actionlog->calcNextAuditDate(null, $actionlog->item), 'date') : null,
|
||||
'days_to_next_audit' => $actionlog->daysUntilNextAudit($settings->audit_interval, $actionlog->item),
|
||||
'next_audit_date' => $this->getNextAuditDate($actionlog, $auditInterval),
|
||||
'days_to_next_audit' => $this->getDaysToNextAudit($actionlog, $auditInterval),
|
||||
'action_type' => $actionlog->present()->actionType(),
|
||||
'admin' => ($actionlog->adminuser) ? [
|
||||
'id' => (int) $actionlog->adminuser->id,
|
||||
@@ -210,6 +229,29 @@ class ActionlogsTransformer
|
||||
return (new DatatablesTransformer)->transformDatatables($array, $total);
|
||||
}
|
||||
|
||||
public function formatChangedMetaForCsv(array $meta): ?string
|
||||
{
|
||||
if ($meta === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$formattedChanges = [];
|
||||
|
||||
foreach ($meta as $field => $change) {
|
||||
if (! is_array($change)) {
|
||||
$formattedChanges[] = sprintf('%s: %s', $field, $this->stringifyChangedValueForCsv($change));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$old = $this->stringifyChangedValueForCsv($change['old'] ?? null);
|
||||
$new = $this->stringifyChangedValueForCsv($change['new'] ?? null);
|
||||
$formattedChanges[] = sprintf('%s (old: %s, new: %s)', $field, $old, $new);
|
||||
}
|
||||
|
||||
return implode(' | ', $formattedChanges);
|
||||
}
|
||||
|
||||
/**
|
||||
* This takes the ids of the changed attributes and returns the names instead for the history view of an Asset
|
||||
*
|
||||
@@ -348,4 +390,78 @@ class ActionlogsTransformer
|
||||
|
||||
return (int) $actionlog->quantity;
|
||||
}
|
||||
|
||||
private function shouldHideSensitiveMetaField(string $fieldName): bool
|
||||
{
|
||||
if (! in_array($fieldName, self::CONTACT_INFO_META_FIELDS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! auth()->user()?->can('manageContactInfo', User::class);
|
||||
}
|
||||
|
||||
private function stringifyChangedValueForCsv(mixed $value): string
|
||||
{
|
||||
if (is_null($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
return (string) json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
$decodedValue = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$jsonValue = json_decode($decodedValue, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($jsonValue)) {
|
||||
return (string) json_encode($jsonValue, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
return $decodedValue;
|
||||
}
|
||||
|
||||
private function normalizeAuditInterval(mixed $auditInterval): ?int
|
||||
{
|
||||
if (! is_numeric($auditInterval)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = (int) $auditInterval;
|
||||
|
||||
return $normalized > 0 ? $normalized : null;
|
||||
}
|
||||
|
||||
private function getNextAuditDate(Actionlog $actionlog, ?int $auditInterval): mixed
|
||||
{
|
||||
if ($actionlog->itemType() !== 'asset' || ! $actionlog->item instanceof Asset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $actionlog->item->next_audit_date && $auditInterval === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Helper::getFormattedDateObject($actionlog->calcNextAuditDate($auditInterval, $actionlog->item), 'date');
|
||||
}
|
||||
|
||||
private function getDaysToNextAudit(Actionlog $actionlog, ?int $auditInterval): ?int
|
||||
{
|
||||
if (! $actionlog->item instanceof Asset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $actionlog->item->next_audit_date && $auditInterval === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $actionlog->daysUntilNextAudit($auditInterval, $actionlog->item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,18 @@ class UsersTransformer
|
||||
} elseif ($user->isAdmin()) {
|
||||
$role = 'admin';
|
||||
}
|
||||
$sensitive_fields = [
|
||||
'email' => ($user->email) ? e($user->email) : null,
|
||||
'phone' => ($user->phone) ? e($user->phone) : null,
|
||||
'mobile' => ($user->mobile) ? e($user->mobile) : null,
|
||||
'website' => ($user->website) ? e($user->website) : null,
|
||||
'address' => ($user->address) ? e($user->address) : null,
|
||||
'city' => ($user->city) ? e($user->city) : null,
|
||||
'state' => ($user->state) ? e($user->state) : null,
|
||||
'country' => ($user->country) ? e($user->country) : null,
|
||||
'zip' => ($user->zip) ? e($user->zip) : null,
|
||||
];
|
||||
|
||||
$array = [
|
||||
'id' => (int) $user->id,
|
||||
'avatar' => e($user->present()->gravatar) ?? null,
|
||||
@@ -45,15 +57,14 @@ class UsersTransformer
|
||||
] : null,
|
||||
'jobtitle' => ($user->jobtitle) ? e($user->jobtitle) : null,
|
||||
'vip' => ($user->vip == '1') ? true : false,
|
||||
'phone' => ($user->phone) ? e($user->phone) : null,
|
||||
'mobile' => ($user->mobile) ? e($user->mobile) : null,
|
||||
'website' => ($user->website) ? e($user->website) : null,
|
||||
'address' => ($user->address) ? e($user->address) : null,
|
||||
'city' => ($user->city) ? e($user->city) : null,
|
||||
'state' => ($user->state) ? e($user->state) : null,
|
||||
'country' => ($user->country) ? e($user->country) : null,
|
||||
'zip' => ($user->zip) ? e($user->zip) : null,
|
||||
'email' => ($user->email) ? e($user->email) : null,
|
||||
];
|
||||
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
$array += $sensitive_fields;
|
||||
}
|
||||
|
||||
$array += [
|
||||
|
||||
'department' => ($user->department) ? [
|
||||
'id' => (int) $user->department->id,
|
||||
'name' => e($user->department->name),
|
||||
|
||||
@@ -74,6 +74,7 @@ class UserImporter extends ItemImporter
|
||||
$this->item['autoassign_licenses'] = ($this->fetchHumanBoolean(trim($this->findCsvMatch($row, 'autoassign_licenses'))) == 1) ? '1' : 0;
|
||||
|
||||
$this->handleEmptyStringsForDates();
|
||||
$this->stripSensitiveContactFieldsWhenUnauthorized();
|
||||
|
||||
$user_department = trim($this->findCsvMatch($row, 'department'));
|
||||
if ($this->shouldUpdateField($user_department)) {
|
||||
@@ -220,4 +221,29 @@ class UserImporter extends ItemImporter
|
||||
$this->item['end_date'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
private function stripSensitiveContactFieldsWhenUnauthorized(): void
|
||||
{
|
||||
if (! $this->shouldRestrictSensitiveContactFields()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (User::SENSITIVE_CONTACT_FIELDS as $field) {
|
||||
unset($this->item[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldRestrictSensitiveContactFields(): bool
|
||||
{
|
||||
// True CLI imports (no authenticated user) are intentionally trusted.
|
||||
if (app()->runningInConsole() && ! Auth::check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! Auth::check()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::allows('manageContactInfo', User::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire;
|
||||
|
||||
use App\Models\CustomField;
|
||||
use App\Models\Import;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
@@ -722,6 +723,16 @@ class Importer extends Component
|
||||
$this->message_type = null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function showUserImportContactWarning(): bool
|
||||
{
|
||||
if ($this->typeOfImport !== 'user') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return auth()->user()?->cannot('manageContactInfo', User::class) ?? false;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function files()
|
||||
{
|
||||
|
||||
@@ -104,6 +104,15 @@ class Actionlog extends SnipeModel
|
||||
'accessories.supplier' => ['name', 'notes'],
|
||||
];
|
||||
|
||||
protected function canApplyStructuredFilterToAttribute(string $attribute): bool
|
||||
{
|
||||
if ($attribute !== 'log_meta') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return auth()->user()?->can('manageContactInfo', User::class) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override from Builder to automatically add the company
|
||||
*
|
||||
|
||||
@@ -204,6 +204,17 @@ class SnipeModel extends Model
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to set a base email attribute that the base model, so we can override it with the
|
||||
* manageContactInfo permission check in the User model
|
||||
*/
|
||||
protected function displayEmail(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value, array $attributes) => array_key_exists('email', $attributes) ? $attributes['email'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function getEula()
|
||||
{
|
||||
|
||||
|
||||
@@ -180,16 +180,26 @@ trait Searchable
|
||||
$searchableCounts = $this->getSearchableCounts();
|
||||
$searchableRelations = $this->getSearchableRelations();
|
||||
$table = $this->getTable();
|
||||
$appliedFilterCount = 0;
|
||||
$blockedAttributeFilterCount = 0;
|
||||
|
||||
foreach ($filters as $filterKey => $filterValue) {
|
||||
if (in_array($filterKey, $searchableAttributes, true)) {
|
||||
if (! $this->canApplyStructuredFilterToAttribute($filterKey)) {
|
||||
$blockedAttributeFilterCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$query->where($table.'.'.$filterKey, 'LIKE', '%'.$filterValue.'%');
|
||||
$appliedFilterCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($filterKey, $searchableCounts, true)) {
|
||||
$query = $this->applyCountAliasFilter($query, $filterKey, $filterValue);
|
||||
$appliedFilterCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -202,6 +212,7 @@ trait Searchable
|
||||
|
||||
if ($dbColumn !== null) {
|
||||
$query->where($table.'.'.$dbColumn, 'LIKE', '%'.$filterValue.'%');
|
||||
$appliedFilterCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -215,6 +226,7 @@ trait Searchable
|
||||
|
||||
if ($this->isAssignedToRelationKey($resolvedRelationKey)) {
|
||||
$query = $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $filterValue);
|
||||
$appliedFilterCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -249,6 +261,17 @@ trait Searchable
|
||||
);
|
||||
}
|
||||
});
|
||||
$appliedFilterCount++;
|
||||
}
|
||||
|
||||
// If the request only contains blocked sensitive attribute filters,
|
||||
// force no results instead of returning broad unfiltered data.
|
||||
// This is specificially used in the User model to block filtering on email/phone/etc. for users
|
||||
// without the Policies/UserPolicy.php -> manageContactInfo permission, but this is a good safeguard
|
||||
// in general to prevent accidentally returning unfiltered results when all
|
||||
// provided filters are invalid or blocked.
|
||||
if (($appliedFilterCount === 0) && ($blockedAttributeFilterCount > 0)) {
|
||||
$query->whereKey([]);
|
||||
}
|
||||
|
||||
return $query;
|
||||
@@ -407,6 +430,10 @@ trait Searchable
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($this->getSearchableAttributes() as $column) {
|
||||
if (! $this->canApplyStructuredFilterToAttribute($column)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($terms as $term) {
|
||||
/**
|
||||
* Making sure to only search in date columns if the search term consists of characters that can make up a MySQL timestamp!
|
||||
@@ -635,6 +662,14 @@ trait Searchable
|
||||
return $this->searchableAttributes ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow models to deny structured filters for specific attributes.
|
||||
*/
|
||||
protected function canApplyStructuredFilterToAttribute(string $attribute): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the searchable relations, if defined. Otherwise it returns an empty array
|
||||
*
|
||||
|
||||
+109
-6
@@ -45,6 +45,18 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
use Searchable;
|
||||
use UniqueUndeletedTrait;
|
||||
|
||||
public const SENSITIVE_CONTACT_FIELDS = [
|
||||
'email',
|
||||
'phone',
|
||||
'mobile',
|
||||
'website',
|
||||
'address',
|
||||
'city',
|
||||
'state',
|
||||
'country',
|
||||
'zip',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
@@ -217,6 +229,77 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* These fields should be hidden if the requesting user cannot view contact info. The nav panel will try to
|
||||
* automagically display these values in the sidebar, but we don't want that if they're not supposed to see them
|
||||
*/
|
||||
protected function address(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => (auth()->user() && auth()->user()->can('manageContactInfo', User::class)) ? $value : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function city(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => (auth()->user() && auth()->user()->can('manageContactInfo', User::class)) ? $value : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function state(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => (auth()->user() && auth()->user()->can('manageContactInfo', User::class)) ? $value : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function country(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => (auth()->user() && auth()->user()->can('manageContactInfo', User::class)) ? $value : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function zip(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => (auth()->user() && auth()->user()->can('manageContactInfo', User::class)) ? $value : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function phone(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => (auth()->user() && auth()->user()->can('manageContactInfo', User::class)) ? $value : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function mobile(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => (auth()->user() && auth()->user()->can('manageContactInfo', User::class)) ? $value : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function url(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value, array $attributes) => (auth()->user()?->can('manageContactInfo', User::class))
|
||||
? ($attributes['website'] ?? $value)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function displayEmail(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value, array $attributes) => (auth()->user()?->can('manageContactInfo', User::class))
|
||||
? ($attributes['email'] ?? $value)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all Passport access/refresh tokens associated with this user.
|
||||
*/
|
||||
@@ -1145,9 +1228,11 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
*/
|
||||
public function scopeSimpleNameSearch($query, $search)
|
||||
{
|
||||
return $query->where('first_name', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('last_name', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('display_name', 'LIKE', '%'.$search.'%')
|
||||
return $query->where('users.first_name', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.last_name', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.username', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.display_name', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.employee_num', 'LIKE', '%'.$search.'%')
|
||||
->orWhereMultipleColumns(
|
||||
[
|
||||
'users.first_name',
|
||||
@@ -1172,11 +1257,32 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
'users.last_name',
|
||||
], $term
|
||||
);
|
||||
|
||||
if (auth()->user()?->can('manageContactInfo', User::class)) {
|
||||
$query->orWhere('users.email', 'LIKE', '%'.$term.'%')
|
||||
->orWhere('users.phone', 'LIKE', '%'.$term.'%')
|
||||
->orWhere('users.mobile', 'LIKE', '%'.$term.'%')
|
||||
->orWhere('users.address', 'LIKE', '%'.$term.'%')
|
||||
->orWhere('users.city', 'LIKE', '%'.$term.'%')
|
||||
->orWhere('users.state', 'LIKE', '%'.$term.'%')
|
||||
->orWhere('users.country', 'LIKE', '%'.$term.'%')
|
||||
->orWhere('users.zip', 'LIKE', '%'.$term.'%')
|
||||
->orWhere('users.website', 'LIKE', '%'.$term.'%');
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function canApplyStructuredFilterToAttribute(string $attribute): bool
|
||||
{
|
||||
if (! in_array($attribute, self::SENSITIVE_CONTACT_FIELDS, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return auth()->user()?->can('manageContactInfo', User::class) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder scope to return users by group
|
||||
*
|
||||
@@ -1339,11 +1445,8 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
|
||||
return $query->where('location_id', '=', $location)
|
||||
->where('users.first_name', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.email', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.last_name', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.permissions', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.country', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.phone', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.jobtitle', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.employee_num', 'LIKE', '%'.$search.'%')
|
||||
->orWhere('users.username', 'LIKE', '%'.$search.'%')
|
||||
|
||||
@@ -61,6 +61,20 @@ class UserObserver
|
||||
// Check and see if the value changed
|
||||
if ($user->getRawOriginal()[$key] != $user->getAttributes()[$key]) {
|
||||
|
||||
if ($key == 'permissions') {
|
||||
$permissionsChangeSet = $this->buildPermissionsChangeSet(
|
||||
$user->getRawOriginal()[$key],
|
||||
$user->getAttributes()[$key]
|
||||
);
|
||||
|
||||
if (! empty($permissionsChangeSet)) {
|
||||
$changed[$key]['old'] = $permissionsChangeSet['old'];
|
||||
$changed[$key]['new'] = $permissionsChangeSet['new'];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed[$key]['old'] = $user->getRawOriginal()[$key];
|
||||
$changed[$key]['new'] = $user->getAttributes()[$key];
|
||||
|
||||
@@ -89,6 +103,62 @@ class UserObserver
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact permission diff so log_meta only includes changed permission keys.
|
||||
*/
|
||||
private function buildPermissionsChangeSet(mixed $oldPermissions, mixed $newPermissions): array
|
||||
{
|
||||
$old = $this->decodePermissions($oldPermissions);
|
||||
$new = $this->decodePermissions($newPermissions);
|
||||
|
||||
if ($old === null || $new === null) {
|
||||
return [
|
||||
'old' => $oldPermissions,
|
||||
'new' => $newPermissions,
|
||||
];
|
||||
}
|
||||
|
||||
$changedOld = [];
|
||||
$changedNew = [];
|
||||
$keys = array_unique(array_merge(array_keys($old), array_keys($new)));
|
||||
|
||||
foreach ($keys as $permissionKey) {
|
||||
$oldValue = $old[$permissionKey] ?? null;
|
||||
$newValue = $new[$permissionKey] ?? null;
|
||||
|
||||
if ((string) $oldValue === (string) $newValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changedOld[$permissionKey] = $oldValue;
|
||||
$changedNew[$permissionKey] = $newValue;
|
||||
}
|
||||
|
||||
if (($changedOld === []) && ($changedNew === [])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'old' => $changedOld,
|
||||
'new' => $changedNew,
|
||||
];
|
||||
}
|
||||
|
||||
private function decodePermissions(mixed $permissions): ?array
|
||||
{
|
||||
if (is_array($permissions)) {
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
if (! is_string($permissions) || trim($permissions) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($permissions, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to the User created event, and increment
|
||||
* the next_auto_tag_base value in the settings table when i
|
||||
|
||||
@@ -15,4 +15,9 @@ class UserPolicy extends SnipePermissionsPolicy
|
||||
{
|
||||
return $user->hasAccess($this->columnName().'.files');
|
||||
}
|
||||
|
||||
public function manageContactInfo(User $user)
|
||||
{
|
||||
return $user->hasAccess('users.contact');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Presenters;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,7 @@ class UserPresenter extends Presenter
|
||||
],
|
||||
[
|
||||
'field' => 'name',
|
||||
'searchable' => true,
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('admin/users/table.name'),
|
||||
'visible' => true,
|
||||
@@ -126,6 +127,9 @@ class UserPresenter extends Presenter
|
||||
'visible' => false,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
],
|
||||
];
|
||||
|
||||
$sensitive_fields = [
|
||||
[
|
||||
'field' => 'email',
|
||||
'searchable' => true,
|
||||
@@ -202,15 +206,23 @@ class UserPresenter extends Presenter
|
||||
'title' => trans('general.zip'),
|
||||
'visible' => false,
|
||||
],
|
||||
];
|
||||
|
||||
[
|
||||
'field' => 'locale',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.language'),
|
||||
'visible' => false,
|
||||
],
|
||||
// Add the sensitive fields in if the user can see them
|
||||
if (auth()->user()->can('manageContactInfo', User::class)) {
|
||||
foreach ($sensitive_fields as $sensitive_field) {
|
||||
array_push($layout, $sensitive_field);
|
||||
}
|
||||
}
|
||||
|
||||
array_push($layout, [
|
||||
'field' => 'locale',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.language'),
|
||||
'visible' => false,
|
||||
],
|
||||
[
|
||||
'field' => 'department',
|
||||
'searchable' => true,
|
||||
@@ -432,7 +444,7 @@ class UserPresenter extends Presenter
|
||||
'printIgnore' => true,
|
||||
'class' => 'hidden-print',
|
||||
],
|
||||
];
|
||||
);
|
||||
|
||||
return json_encode($layout);
|
||||
}
|
||||
|
||||
@@ -251,6 +251,10 @@ return [
|
||||
'permission' => 'users.delete',
|
||||
'display' => true,
|
||||
],
|
||||
[
|
||||
'permission' => 'users.contact',
|
||||
'display' => true,
|
||||
],
|
||||
[
|
||||
'permission' => 'users.files',
|
||||
'display' => true,
|
||||
|
||||
@@ -355,6 +355,11 @@ class UserFactory extends Factory
|
||||
return $this->appendPermission(['users.edit' => '1']);
|
||||
}
|
||||
|
||||
public function manageContactInfo()
|
||||
{
|
||||
return $this->appendPermission(['users.contact' => '1']);
|
||||
}
|
||||
|
||||
public function deleteUsers()
|
||||
{
|
||||
return $this->appendPermission(['users.delete' => '1']);
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$this->backfillPermissionsForTable('users');
|
||||
$this->backfillPermissionsForTable('permission_groups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// This is a data backfill migration; no rollback is performed to avoid removing intentional permission changes.
|
||||
}
|
||||
|
||||
private function backfillPermissionsForTable(string $table): void
|
||||
{
|
||||
DB::table($table)
|
||||
->select(['id', 'permissions'])
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($rows) use ($table): void {
|
||||
foreach ($rows as $row) {
|
||||
$permissions = json_decode((string) ($row->permissions ?? '{}'), true);
|
||||
|
||||
if (! is_array($permissions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists('users.contact', $permissions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$editPermission = $permissions['users.edit'] ?? null;
|
||||
|
||||
if (! $this->isGrantedPermission($editPermission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preserve the existing permission value style (string/int/bool) where possible.
|
||||
$permissions['users.contact'] = $editPermission;
|
||||
|
||||
DB::table($table)
|
||||
->where('id', $row->id)
|
||||
->update([
|
||||
'permissions' => json_encode($permissions),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function isGrantedPermission(mixed $permissionValue): bool
|
||||
{
|
||||
return in_array($permissionValue, [1, '1', true], true);
|
||||
}
|
||||
};
|
||||
@@ -57,6 +57,7 @@ return [
|
||||
'all_assigned_list_generation' => 'Generated on:',
|
||||
'email_user_creds_on_create' => 'Email this user their credentials?',
|
||||
'department_manager' => 'Department Manager',
|
||||
'import_contact_fields_permission_warning' => 'You do not have permission to import user contact fields (email, phone, mobile, website, or address details). Those columns will be ignored if they are included in this import.',
|
||||
'generate_password' => 'Generate random password',
|
||||
'individual_override' => 'This user has at least one individual permission set, which may override group permissions.',
|
||||
];
|
||||
|
||||
@@ -117,7 +117,12 @@ return [
|
||||
|
||||
'usersfiles' => [
|
||||
'name' => 'Manage User Files',
|
||||
'note' => 'Allows the user to upload, download, and delete files associated with users. (This only makes sense with view privileges or higher.)',
|
||||
'note' => 'Allows the user to view, upload, download, and delete files associated with users. (This only makes sense with view privileges or higher.) Keep in mind that some uploaded user files might contain personal information that should be protected, so only grant this permission to trusted users.',
|
||||
],
|
||||
|
||||
'userscontact' => [
|
||||
'name' => 'View/Edit User Contact Info',
|
||||
'note' => 'Allows the user to view and edit personal contact information about the user. This includes: address, city, state/province, country, postal code, phone number, mobile number, email address, and website.',
|
||||
],
|
||||
|
||||
'modelsfiles' => [
|
||||
|
||||
@@ -312,11 +312,11 @@
|
||||
</x-info-element>
|
||||
@endif
|
||||
|
||||
@if ($infoPanelObj->email)
|
||||
@if ($infoPanelObj->display_email)
|
||||
<x-info-element icon_type="email" title="{{ trans('general.email') }}">
|
||||
<x-copy-to-clipboard class="pull-right" copy_what="email">
|
||||
<x-info-element.email title="{{ trans('general.email') }}">
|
||||
{{ $infoPanelObj->email }}
|
||||
{{ $infoPanelObj->display_email }}
|
||||
</x-info-element.email>
|
||||
</x-copy-to-clipboard>
|
||||
</x-info-element>
|
||||
@@ -365,8 +365,9 @@
|
||||
</x-info-element>
|
||||
@endif
|
||||
|
||||
@php($canViewInfoPanelMap = ! ($infoPanelObj instanceof \App\Models\User) || auth()->user()?->can('manageContactInfo', \App\Models\User::class))
|
||||
|
||||
@if (($infoPanelObj->present()->displayAddress) && (config('services.google.maps_api_key')))
|
||||
@if ($canViewInfoPanelMap && ($infoPanelObj->present()->displayAddress) && (config('services.google.maps_api_key')))
|
||||
|
||||
<x-info-element>
|
||||
<div class="text-center">
|
||||
@@ -375,7 +376,7 @@
|
||||
</x-info-element>
|
||||
@endif
|
||||
|
||||
@if ((($infoPanelObj->address!='') && ($infoPanelObj->city!='')) || ($infoPanelObj->state!='') || ($infoPanelObj->country!=''))
|
||||
@if ($canViewInfoPanelMap && ((($infoPanelObj->address!='') && ($infoPanelObj->city!='')) || ($infoPanelObj->state!='') || ($infoPanelObj->country!='')))
|
||||
<x-info-element>
|
||||
<a class="btn btn-sm btn-theme" href="https://maps.google.com/?q={{ urlencode($infoPanelObj->address.','. $infoPanelObj->city.','.$infoPanelObj->state.','.$infoPanelObj->country.','.$infoPanelObj->zip) }}" target="_blank">
|
||||
<x-icon type="google" class="hidden-print"/>
|
||||
|
||||
@@ -199,6 +199,13 @@
|
||||
{{ trans('general.auto_incrementing_asset_tags_disabled_so_tags_required') }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if($this->showUserImportContactWarning)
|
||||
<p class="text-warning">
|
||||
<x-icon type="warning" class="fa-fw"/>
|
||||
{{ trans('admin/users/general.import_contact_fields_permission_warning') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
</style>
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-md-offset-2">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
|
||||
<p>{{ trans('admin/users/general.bulk_update_help') }}</p>
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manageContactInfo', $user)
|
||||
<!-- City -->
|
||||
<div class="form-group{{ $errors->has('city') ? ' has-error' : '' }}">
|
||||
<label class="col-md-3 control-label" for="city">{{ trans('general.city') }}</label>
|
||||
@@ -117,6 +118,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group{{ $errors->has('state') ? ' has-error' : '' }}">
|
||||
<label class="col-md-3 control-label" for="state">{{ trans('general.state') }}</label>
|
||||
<div class="col-md-4">
|
||||
<input class="form-control" type="text" name="state" id="state" aria-label="state"/>
|
||||
{!! $errors->first('state', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Country -->
|
||||
<div class="form-group{{ $errors->has('country') ? ' has-error' : '' }}">
|
||||
<label class="col-md-3 control-label" for="country">{{ trans('general.country') }}</label>
|
||||
<div class="col-md-6">
|
||||
<x-input.country-select
|
||||
name="country"
|
||||
class="col-md-12"
|
||||
/>
|
||||
|
||||
<p class="help-block">{{ trans('general.countries_manually_entered_help') }}</p>
|
||||
{!! $errors->first('country', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group{{ $errors->has('zip') ? ' has-error' : '' }}">
|
||||
<label class="col-md-3 control-label" for="zip">{{ trans('general.zip') }}</label>
|
||||
<div class="col-md-4">
|
||||
<input class="form-control" type="text" name="zip" id="zip" aria-label="zip"/>
|
||||
{!! $errors->first('zip', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
<!-- remote -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-3 control-label">
|
||||
@@ -162,7 +194,7 @@
|
||||
</div>
|
||||
</div> <!--/form-group-->
|
||||
|
||||
<!-- activated -->
|
||||
<!-- autoassign -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-3 control-label">
|
||||
{{ trans('general.autoassign_licenses') }}
|
||||
|
||||
@@ -39,8 +39,7 @@
|
||||
|
||||
</style>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-md-offset-2">
|
||||
<x-container class="col-md-8 col-md-offset-2">
|
||||
<form class="form-horizontal" method="post" autocomplete="off"
|
||||
action="{{ (isset($user->id)) ? route('users.update', ['user' => $user->id]) : route('users.store') }}"
|
||||
enctype="multipart/form-data" id="userForm">
|
||||
@@ -237,6 +236,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manageContactInfo', \App\Models\User::class)
|
||||
<!-- Email -->
|
||||
<div class="form-group {{ $errors->has('email') ? 'has-error' : '' }}">
|
||||
<label class="col-md-3 control-label" for="email">{{ trans('admin/users/table.email') }} </label>
|
||||
@@ -288,6 +288,7 @@
|
||||
</div>
|
||||
</div> <!--/form-group-->
|
||||
@endif
|
||||
@endcan
|
||||
|
||||
|
||||
@include ('partials.forms.edit.image-upload', ['fieldname' => 'avatar', 'image_path' => app('users_upload_path')])
|
||||
@@ -444,6 +445,9 @@
|
||||
<!-- Location -->
|
||||
@include ('partials.forms.edit.location-select', ['translated_name' => trans('general.location'), 'fieldname' => 'location_id'])
|
||||
|
||||
|
||||
@can('manageContactInfo', \App\Models\User::class)
|
||||
|
||||
<!-- Phone -->
|
||||
<div class="form-group {{ $errors->has('phone') ? 'has-error' : '' }}">
|
||||
<label class="col-md-3 control-label" for="phone">{{ trans('admin/users/table.phone') }}</label>
|
||||
@@ -453,7 +457,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<!-- Mobile -->
|
||||
<div class="form-group {{ $errors->has('mobile') ? 'has-error' : '' }}">
|
||||
<label class="col-md-3 control-label" for="phone">{{ trans('admin/users/table.mobile') }}</label>
|
||||
<div class="col-md-6">
|
||||
@@ -521,17 +525,19 @@
|
||||
{!! $errors->first('zip', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-group{!! $errors->has('notes') ? ' has-error' : '' !!}">
|
||||
<label for="notes" class="col-md-3 control-label">{{ trans('admin/users/table.notes') }}</label>
|
||||
<div class="col-md-6">
|
||||
<textarea class="form-control" rows="5" id="notes" name="notes">{{ old('notes', $user->notes) }}</textarea>
|
||||
{!! $errors->first('notes', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
|
||||
</div>
|
||||
</div>
|
||||
<x-form.row
|
||||
:label="trans('general.notes')"
|
||||
:item="$user"
|
||||
name="notes"
|
||||
type="textarea"
|
||||
placeholder="{{ trans('general.placeholders.notes') }}"
|
||||
/>
|
||||
|
||||
@if ($snipeSettings->two_factor_enabled!='')
|
||||
|
||||
@if ($snipeSettings->two_factor_enabled!='')
|
||||
@if ($snipeSettings->two_factor_enabled=='1')
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
@@ -583,7 +589,7 @@
|
||||
<label class="col-md-3 control-label" for="groups[]">
|
||||
{{ trans('general.groups') }}
|
||||
</label>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-8">
|
||||
|
||||
@if ($groups->count())
|
||||
@if ((!Gate::allows('editableOnDemo') || (!Auth::user()->isSuperUser())))
|
||||
@@ -675,8 +681,7 @@
|
||||
/>
|
||||
</div><!-- nav-tabs-custom -->
|
||||
</form>
|
||||
</div> <!--/col-md-8-->
|
||||
</div><!--/row-->
|
||||
</x-container>
|
||||
@stop
|
||||
|
||||
@section('moar_scripts')
|
||||
|
||||
@@ -61,8 +61,8 @@ class AuditAssetTest extends TestCase
|
||||
|
||||
public function test_asset_audit_is_saved()
|
||||
{
|
||||
$expectedAuditDate = now()->toDateString();
|
||||
$asset = Asset::factory()->create(['next_audit_date' => now()->subMonth()->toDateString()]);
|
||||
$now = now();
|
||||
$future = now()->addMonths(3)->toDateString();
|
||||
|
||||
$this->actingAsForApi(User::factory()->auditAssets()->create())
|
||||
@@ -85,7 +85,8 @@ class AuditAssetTest extends TestCase
|
||||
$this->assertHasTheseActionLogs($asset, ['create', 'audit']);
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertEquals($now, $asset->last_audit_date);
|
||||
$this->assertNotNull($asset->last_audit_date);
|
||||
$this->assertSame($expectedAuditDate, (string) substr((string) $asset->last_audit_date, 0, 10));
|
||||
$this->assertEquals($future, $asset->next_audit_date);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Database;
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BackfillUsersContactPermissionMigrationTest extends TestCase
|
||||
{
|
||||
public function test_migration_backfills_users_contact_permission_for_users_and_groups_with_users_edit(): void
|
||||
{
|
||||
$userWithEdit = User::factory()->create([
|
||||
'permissions' => json_encode([
|
||||
'users.edit' => '1',
|
||||
]),
|
||||
]);
|
||||
|
||||
$userWithoutEdit = User::factory()->create([
|
||||
'permissions' => json_encode([
|
||||
'users.view' => '1',
|
||||
]),
|
||||
]);
|
||||
|
||||
$groupWithEdit = Group::factory()->create([
|
||||
'permissions' => json_encode([
|
||||
'users.edit' => 1,
|
||||
]),
|
||||
]);
|
||||
|
||||
$groupWithoutEdit = Group::factory()->create([
|
||||
'permissions' => json_encode([
|
||||
'users.view' => 1,
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->runBackfillMigration();
|
||||
|
||||
$this->assertSame('1', (string) $this->permissionForUser($userWithEdit->id, 'users.contact'));
|
||||
$this->assertNull($this->permissionForUser($userWithoutEdit->id, 'users.contact'));
|
||||
|
||||
$this->assertSame('1', (string) $this->permissionForGroup($groupWithEdit->id, 'users.contact'));
|
||||
$this->assertNull($this->permissionForGroup($groupWithoutEdit->id, 'users.contact'));
|
||||
}
|
||||
|
||||
public function test_migration_does_not_override_existing_users_contact_permission(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'permissions' => json_encode([
|
||||
'users.edit' => '1',
|
||||
'users.contact' => '-1',
|
||||
]),
|
||||
]);
|
||||
|
||||
$group = Group::factory()->create([
|
||||
'permissions' => json_encode([
|
||||
'users.edit' => 1,
|
||||
'users.contact' => -1,
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->runBackfillMigration();
|
||||
|
||||
$this->assertSame('-1', (string) $this->permissionForUser($user->id, 'users.contact'));
|
||||
$this->assertSame('-1', (string) $this->permissionForGroup($group->id, 'users.contact'));
|
||||
}
|
||||
|
||||
private function runBackfillMigration(): void
|
||||
{
|
||||
/** @var Migration $migration */
|
||||
$migration = require database_path('migrations/2026_04_17_120000_backfill_users_contact_permission_from_users_edit.php');
|
||||
$migration->up();
|
||||
}
|
||||
|
||||
private function permissionForUser(int $userId, string $permission): mixed
|
||||
{
|
||||
$permissions = json_decode((string) DB::table('users')->where('id', $userId)->value('permissions'), true);
|
||||
|
||||
return is_array($permissions) ? ($permissions[$permission] ?? null) : null;
|
||||
}
|
||||
|
||||
private function permissionForGroup(int $groupId, string $permission): mixed
|
||||
{
|
||||
$permissions = json_decode((string) DB::table('permission_groups')->where('id', $groupId)->value('permissions'), true);
|
||||
|
||||
return is_array($permissions) ? ($permissions[$permission] ?? null) : null;
|
||||
}
|
||||
}
|
||||
@@ -498,4 +498,134 @@ class IndexHistoryTest extends TestCase
|
||||
$this->assertLessThan(45, $queryCount, 'History endpoint query count regressed and may have reintroduced N+1 behavior.');
|
||||
$this->assertCount(30, $response->json('rows'));
|
||||
}
|
||||
|
||||
public function test_user_history_hides_sensitive_contact_fields_in_log_meta_without_permission()
|
||||
{
|
||||
$subject = User::factory()->create();
|
||||
$actor = User::factory()->viewUserHistory()->create();
|
||||
$uniqueNote = 'history-hide-contact-meta-'.uniqid();
|
||||
|
||||
Actionlog::factory()->create([
|
||||
'item_id' => $subject->id,
|
||||
'item_type' => User::class,
|
||||
'target_id' => $subject->id,
|
||||
'target_type' => User::class,
|
||||
'created_by' => $actor->id,
|
||||
'action_type' => 'update',
|
||||
'note' => $uniqueNote,
|
||||
'log_meta' => json_encode([
|
||||
'email' => ['old' => 'old@example.test', 'new' => 'new@example.test'],
|
||||
'phone' => ['old' => '111', 'new' => '222'],
|
||||
'first_name' => ['old' => 'Old', 'new' => 'New'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $this->actingAsForApi($actor)
|
||||
->getJson(route('api.users.history', [
|
||||
'user' => $subject,
|
||||
'search' => $uniqueNote,
|
||||
]))
|
||||
->assertOk();
|
||||
|
||||
$logMeta = $response->json('rows.0.log_meta');
|
||||
|
||||
$this->assertIsArray($logMeta);
|
||||
$this->assertArrayNotHasKey('email', $logMeta);
|
||||
$this->assertArrayNotHasKey('phone', $logMeta);
|
||||
$this->assertArrayHasKey('first_name', $logMeta);
|
||||
}
|
||||
|
||||
public function test_user_history_shows_sensitive_contact_fields_in_log_meta_with_permission()
|
||||
{
|
||||
$subject = User::factory()->create();
|
||||
$actor = User::factory()->viewUserHistory()->manageContactInfo()->create();
|
||||
$uniqueNote = 'history-show-contact-meta-'.uniqid();
|
||||
|
||||
Actionlog::factory()->create([
|
||||
'item_id' => $subject->id,
|
||||
'item_type' => User::class,
|
||||
'target_id' => $subject->id,
|
||||
'target_type' => User::class,
|
||||
'created_by' => $actor->id,
|
||||
'action_type' => 'update',
|
||||
'note' => $uniqueNote,
|
||||
'log_meta' => json_encode([
|
||||
'email' => ['old' => 'old@example.test', 'new' => 'new@example.test'],
|
||||
'phone' => ['old' => '111', 'new' => '222'],
|
||||
'first_name' => ['old' => 'Old', 'new' => 'New'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $this->actingAsForApi($actor)
|
||||
->getJson(route('api.users.history', [
|
||||
'user' => $subject,
|
||||
'search' => $uniqueNote,
|
||||
]))
|
||||
->assertOk();
|
||||
|
||||
$logMeta = $response->json('rows.0.log_meta');
|
||||
|
||||
$this->assertIsArray($logMeta);
|
||||
$this->assertArrayHasKey('email', $logMeta);
|
||||
$this->assertArrayHasKey('phone', $logMeta);
|
||||
$this->assertArrayHasKey('first_name', $logMeta);
|
||||
}
|
||||
|
||||
public function test_user_history_search_does_not_match_sensitive_contact_meta_without_permission()
|
||||
{
|
||||
$subject = User::factory()->create();
|
||||
$actor = User::factory()->viewUserHistory()->create();
|
||||
$oldPhone = '555-000-1212';
|
||||
|
||||
Actionlog::factory()->create([
|
||||
'item_id' => $subject->id,
|
||||
'item_type' => User::class,
|
||||
'target_id' => $subject->id,
|
||||
'target_type' => User::class,
|
||||
'created_by' => $actor->id,
|
||||
'action_type' => 'update',
|
||||
'note' => 'history-search-sensitive-meta',
|
||||
'log_meta' => json_encode([
|
||||
'phone' => ['old' => $oldPhone, 'new' => '555-111-2222'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->actingAsForApi($actor)
|
||||
->getJson(route('api.users.history', [
|
||||
'user' => $subject,
|
||||
'search' => $oldPhone,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('total', 0)
|
||||
->assertJsonCount(0, 'rows');
|
||||
}
|
||||
|
||||
public function test_user_history_search_matches_sensitive_contact_meta_with_permission()
|
||||
{
|
||||
$subject = User::factory()->create();
|
||||
$actor = User::factory()->viewUserHistory()->manageContactInfo()->create();
|
||||
$oldPhone = '555-000-3434';
|
||||
|
||||
Actionlog::factory()->create([
|
||||
'item_id' => $subject->id,
|
||||
'item_type' => User::class,
|
||||
'target_id' => $subject->id,
|
||||
'target_type' => User::class,
|
||||
'created_by' => $actor->id,
|
||||
'action_type' => 'update',
|
||||
'note' => 'history-search-sensitive-meta-allowed',
|
||||
'log_meta' => json_encode([
|
||||
'phone' => ['old' => $oldPhone, 'new' => '555-777-8888'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->actingAsForApi($actor)
|
||||
->getJson(route('api.users.history', [
|
||||
'user' => $subject,
|
||||
'search' => $oldPhone,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('total', 1)
|
||||
->assertJsonCount(1, 'rows');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,4 +349,48 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
|
||||
$this->assertNull($newUser->reset_password_code);
|
||||
$this->assertEquals(0, $newUser->activated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function import_user_skips_sensitive_contact_fields_without_manage_contact_permission(): void
|
||||
{
|
||||
$importFileBuilder = ImportFileBuilder::new();
|
||||
$row = $importFileBuilder->firstRow();
|
||||
$import = Import::factory()->users()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||
|
||||
$this->actingAsForApi(User::factory()->canImport()->create());
|
||||
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||
|
||||
$newUser = User::query()
|
||||
->where('username', $row['username'])
|
||||
->sole();
|
||||
|
||||
$this->assertEmpty($newUser->getRawOriginal('email'));
|
||||
$this->assertEmpty($newUser->getRawOriginal('phone'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function update_user_import_skips_sensitive_contact_fields_without_manage_contact_permission(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'username' => Str::random(),
|
||||
'email' => 'original-import-user@example.test',
|
||||
'phone' => '+1-555-0100',
|
||||
]);
|
||||
|
||||
$importFileBuilder = ImportFileBuilder::new([
|
||||
'username' => $user->username,
|
||||
'email' => 'updated-import-user@example.test',
|
||||
'phoneNumber' => '+1-555-9999',
|
||||
]);
|
||||
|
||||
$import = Import::factory()->users()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||
|
||||
$this->actingAsForApi(User::factory()->canImport()->create());
|
||||
$this->importFileResponse(['import' => $import->id, 'import-update' => true])->assertOk();
|
||||
|
||||
$updatedUser = User::query()->findOrFail($user->id);
|
||||
|
||||
$this->assertSame('original-import-user@example.test', $updatedUser->getRawOriginal('email'));
|
||||
$this->assertSame('+1-555-0100', $updatedUser->getRawOriginal('phone'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Livewire\Importer;
|
||||
use App\Models\Import;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
@@ -22,4 +23,26 @@ class ImporterTest extends TestCase
|
||||
->test(Importer::class)
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_shows_user_import_contact_warning_when_actor_lacks_contact_permission(): void
|
||||
{
|
||||
$import = Import::factory()->users()->create();
|
||||
|
||||
Livewire::actingAs(User::factory()->canImport()->create())
|
||||
->test(Importer::class)
|
||||
->call('selectFile', $import->id)
|
||||
->set('typeOfImport', 'user')
|
||||
->assertSee(trans('admin/users/general.import_contact_fields_permission_warning'));
|
||||
}
|
||||
|
||||
public function test_hides_user_import_contact_warning_when_actor_has_contact_permission(): void
|
||||
{
|
||||
$import = Import::factory()->users()->create();
|
||||
|
||||
Livewire::actingAs(User::factory()->canImport()->manageContactInfo()->create())
|
||||
->test(Importer::class)
|
||||
->call('selectFile', $import->id)
|
||||
->set('typeOfImport', 'user')
|
||||
->assertDontSee(trans('admin/users/general.import_contact_fields_permission_warning'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace Tests\Feature\Reporting;
|
||||
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
use League\Csv\Reader;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ActivityReportTest extends TestCase
|
||||
@@ -108,4 +110,121 @@ class ActivityReportTest extends TestCase
|
||||
->assertJson(fn (AssertableJson $json) => $json->has('rows', 7)->etc());
|
||||
|
||||
}
|
||||
|
||||
public function test_activity_search_does_not_match_sensitive_contact_meta_without_permission(): void
|
||||
{
|
||||
$subject = User::factory()->create();
|
||||
$actor = User::factory()->viewUsers()->create();
|
||||
$oldPhone = '555-123-0000';
|
||||
|
||||
Actionlog::factory()->create([
|
||||
'item_id' => $subject->id,
|
||||
'item_type' => User::class,
|
||||
'target_id' => $subject->id,
|
||||
'target_type' => User::class,
|
||||
'created_by' => $actor->id,
|
||||
'action_type' => 'update',
|
||||
'log_meta' => json_encode([
|
||||
'phone' => ['old' => $oldPhone, 'new' => '555-321-0000'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->actingAsForApi($actor)
|
||||
->getJson(route('api.activity.index', [
|
||||
'target_type' => 'user',
|
||||
'target_id' => $subject->id,
|
||||
'search' => $oldPhone,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('total', 0)
|
||||
->assertJsonCount(0, 'rows');
|
||||
}
|
||||
|
||||
public function test_activity_search_matches_sensitive_contact_meta_with_permission(): void
|
||||
{
|
||||
$subject = User::factory()->create();
|
||||
$actor = User::factory()->viewUsers()->manageContactInfo()->create();
|
||||
$oldPhone = '555-123-4444';
|
||||
|
||||
Actionlog::factory()->create([
|
||||
'item_id' => $subject->id,
|
||||
'item_type' => User::class,
|
||||
'target_id' => $subject->id,
|
||||
'target_type' => User::class,
|
||||
'created_by' => $actor->id,
|
||||
'action_type' => 'update',
|
||||
'log_meta' => json_encode([
|
||||
'phone' => ['old' => $oldPhone, 'new' => '555-321-4444'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->actingAsForApi($actor)
|
||||
->getJson(route('api.activity.index', [
|
||||
'target_type' => 'user',
|
||||
'target_id' => $subject->id,
|
||||
'search' => $oldPhone,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('total', 1)
|
||||
->assertJsonCount(1, 'rows');
|
||||
}
|
||||
|
||||
public function test_actionlog_transformer_handles_missing_or_empty_settings(): void
|
||||
{
|
||||
$actor = User::factory()->create();
|
||||
$subject = User::factory()->create();
|
||||
|
||||
$log = Actionlog::factory()->userUpdated()->create([
|
||||
'created_by' => $actor->id,
|
||||
'item_id' => $subject->id,
|
||||
'item_type' => User::class,
|
||||
'target_id' => $subject->id,
|
||||
'target_type' => User::class,
|
||||
]);
|
||||
|
||||
$transformer = new ActionlogsTransformer;
|
||||
|
||||
$withNullSettings = $transformer->transformActionlog($log, null);
|
||||
$withEmptySettingsObject = $transformer->transformActionlog($log, (object) []);
|
||||
|
||||
$this->assertArrayHasKey('days_to_next_audit', $withNullSettings);
|
||||
$this->assertArrayHasKey('days_to_next_audit', $withEmptySettingsObject);
|
||||
}
|
||||
|
||||
public function test_activity_report_csv_formats_changed_column_readably(): void
|
||||
{
|
||||
$actor = User::factory()->canViewReports()->create();
|
||||
$subject = User::factory()->create();
|
||||
|
||||
Actionlog::factory()->create([
|
||||
'created_by' => $actor->id,
|
||||
'item_id' => $subject->id,
|
||||
'item_type' => User::class,
|
||||
'target_id' => $subject->id,
|
||||
'target_type' => User::class,
|
||||
'action_type' => 'update',
|
||||
'log_meta' => json_encode([
|
||||
'permissions' => [
|
||||
'old' => json_encode(['import' => '0']),
|
||||
'new' => json_encode(['import' => '1']),
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($actor)
|
||||
->post(route('reports.activity.post'))
|
||||
->assertOk();
|
||||
|
||||
$csv = Reader::createFromString($response->streamedContent());
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
$rows = collect(iterator_to_array($csv->getRecords(), false));
|
||||
$changedValue = $rows->pluck('Changed')
|
||||
->first(static fn (?string $value): bool => is_string($value) && str_contains($value, 'permissions'));
|
||||
|
||||
$this->assertNotNull($changedValue);
|
||||
$this->assertStringContainsString('{"import":"0"}', $changedValue);
|
||||
$this->assertStringContainsString('{"import":"1"}', $changedValue);
|
||||
$this->assertStringNotContainsString('"', $changedValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,4 +168,38 @@ class CreateUserTest extends TestCase
|
||||
$this->assertSame('1', (string) ($decoded['admin'] ?? null), 'Admin should be able to grant admin during create');
|
||||
$this->assertArrayNotHasKey('superuser', $decoded, 'Admin should not be able to grant superuser during create');
|
||||
}
|
||||
|
||||
public function test_cannot_update_contact_without_permission()
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$response = $this->actingAs(User::factory()->createUsers()->viewUsers()->create())
|
||||
->from(route('users.index'))
|
||||
->post(route('users.store'), [
|
||||
'first_name' => 'Test First Name',
|
||||
'last_name' => 'Test Last Name',
|
||||
'username' => 'testuser',
|
||||
'password' => 'testpassword1235!!',
|
||||
'password_confirmation' => 'testpassword1235!!',
|
||||
'activated' => '1',
|
||||
'email' => 'foo@example.org',
|
||||
'notes' => 'Test Note',
|
||||
])
|
||||
->assertSessionHasNoErrors()
|
||||
->assertStatus(302)
|
||||
->assertRedirect(route('users.index'));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'first_name' => 'Test First Name',
|
||||
'last_name' => 'Test Last Name',
|
||||
'username' => 'testuser',
|
||||
'activated' => '1',
|
||||
'email' => null,
|
||||
'notes' => 'Test Note',
|
||||
|
||||
]);
|
||||
Notification::assertNothingSent();
|
||||
$this->followRedirects($response)->assertSee('Success');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +97,22 @@ class IndexUsersTest extends TestCase
|
||||
])
|
||||
->assertJson(fn (AssertableJson $json) => $json->has('rows', 0)->etc());
|
||||
}
|
||||
|
||||
public function test_contact_only_structured_filter_returns_no_results_without_contact_permission(): void
|
||||
{
|
||||
User::factory()->create([
|
||||
'email' => 'contact-only-filter@example.org',
|
||||
]);
|
||||
|
||||
$this->actingAsForApi(User::factory()->viewUsers()->create())
|
||||
->getJson(route('api.users.index', [
|
||||
'filter' => '{"email":"contact-only-filter@example.org"}',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'total',
|
||||
'rows',
|
||||
])
|
||||
->assertJson(fn (AssertableJson $json) => $json->has('rows', 0)->etc());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Feature\Users\Api;
|
||||
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Company;
|
||||
use App\Models\Department;
|
||||
@@ -13,6 +14,61 @@ use Tests\TestCase;
|
||||
|
||||
class UpdateUserTest extends TestCase
|
||||
{
|
||||
public function test_user_update_log_meta_only_includes_changed_permission_keys(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$targetUser = User::factory()->create([
|
||||
'permissions' => json_encode([
|
||||
'users.view' => '1',
|
||||
'users.edit' => '1',
|
||||
'reports.view' => '1',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->actingAsForApi($admin)
|
||||
->patchJson(route('api.users.update', $targetUser), [
|
||||
'permissions' => [
|
||||
'users.view' => '1', // unchanged
|
||||
'users.edit' => '-1', // changed
|
||||
'licenses.view' => '1', // added
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertStatusMessageIs('success');
|
||||
|
||||
$log = Actionlog::query()
|
||||
->where('item_type', User::class)
|
||||
->where('item_id', $targetUser->id)
|
||||
->where('action_type', 'update')
|
||||
->latest('id')
|
||||
->firstOrFail();
|
||||
|
||||
$logMeta = json_decode((string) $log->log_meta, true);
|
||||
|
||||
$this->assertIsArray($logMeta);
|
||||
$this->assertArrayHasKey('permissions', $logMeta);
|
||||
|
||||
$permissionsMeta = $logMeta['permissions'];
|
||||
$this->assertArrayHasKey('old', $permissionsMeta);
|
||||
$this->assertArrayHasKey('new', $permissionsMeta);
|
||||
|
||||
$this->assertArrayNotHasKey('users.view', $permissionsMeta['old']);
|
||||
$this->assertArrayNotHasKey('users.view', $permissionsMeta['new']);
|
||||
|
||||
$this->assertSame('1', (string) ($permissionsMeta['old']['users.edit'] ?? null));
|
||||
$this->assertSame('-1', (string) ($permissionsMeta['new']['users.edit'] ?? null));
|
||||
|
||||
$this->assertArrayHasKey('reports.view', $permissionsMeta['old']);
|
||||
$this->assertArrayHasKey('reports.view', $permissionsMeta['new']);
|
||||
$this->assertSame('1', (string) $permissionsMeta['old']['reports.view']);
|
||||
$this->assertNull($permissionsMeta['new']['reports.view']);
|
||||
|
||||
$this->assertArrayHasKey('licenses.view', $permissionsMeta['old']);
|
||||
$this->assertArrayHasKey('licenses.view', $permissionsMeta['new']);
|
||||
$this->assertNull($permissionsMeta['old']['licenses.view']);
|
||||
$this->assertSame('1', (string) $permissionsMeta['new']['licenses.view']);
|
||||
}
|
||||
|
||||
public function test_can_update_user_via_patch()
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
@@ -291,6 +347,32 @@ class UpdateUserTest extends TestCase
|
||||
|
||||
}
|
||||
|
||||
public function test_manage_contact_info_without_auth_field_access_cannot_change_admin_email()
|
||||
{
|
||||
$editingUser = User::factory()->editUsers()->manageContactInfo()->create();
|
||||
$adminUser = User::factory()->admin()->create(['email' => 'admin@example.org']);
|
||||
|
||||
$this->actingAsForApi($editingUser)
|
||||
->patch(route('api.users.update', $adminUser), [
|
||||
'email' => 'hijack-admin@example.org',
|
||||
]);
|
||||
|
||||
$this->assertSame('admin@example.org', $adminUser->refresh()->email);
|
||||
}
|
||||
|
||||
public function test_manage_contact_info_without_auth_field_access_cannot_change_superadmin_email()
|
||||
{
|
||||
$editingUser = User::factory()->editUsers()->manageContactInfo()->create();
|
||||
$superuser = User::factory()->superuser()->create(['email' => 'superuser@example.org']);
|
||||
|
||||
$this->actingAsForApi($editingUser)
|
||||
->patch(route('api.users.update', $superuser), [
|
||||
'email' => 'hijack-superuser@example.org',
|
||||
]);
|
||||
|
||||
$this->assertSame('superuser@example.org', $superuser->refresh()->email);
|
||||
}
|
||||
|
||||
public function test_users_scoped_to_company_during_update_when_multiple_full_company_support_enabled()
|
||||
{
|
||||
$this->settings->enableMultipleFullCompanySupport();
|
||||
|
||||
@@ -145,6 +145,31 @@ class UserSearchTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_users_can_be_searched_by_email_with_contact_permission()
|
||||
{
|
||||
User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Skywalker', 'email' => 'luke@jedis.org']);
|
||||
|
||||
Passport::actingAs(User::factory()->viewUsers()->manageContactInfo()->create());
|
||||
|
||||
$response = $this->getJson(route('api.users.index', ['search' => 'luke@jedis']))->assertOk();
|
||||
$results = collect($response->json('rows'));
|
||||
|
||||
$this->assertEquals(1, $results->count());
|
||||
$this->assertTrue($results->pluck('name')->contains(fn ($text) => str_contains($text, 'Luke')));
|
||||
}
|
||||
|
||||
public function test_users_cannot_be_searched_by_email_without_contact_permission()
|
||||
{
|
||||
User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Skywalker', 'email' => 'luke@jedis.org']);
|
||||
|
||||
Passport::actingAs(User::factory()->viewUsers()->create());
|
||||
|
||||
$response = $this->getJson(route('api.users.index', ['search' => 'luke@jedis']))->assertOk();
|
||||
$results = collect($response->json('rows'));
|
||||
|
||||
$this->assertEquals(0, $results->count());
|
||||
}
|
||||
|
||||
public function test_users_index_when_invalid_sort_field_is_passed()
|
||||
{
|
||||
$this->markIncompleteIfSqlite('This test is not compatible with SQLite');
|
||||
|
||||
@@ -44,7 +44,7 @@ class UsersForSelectListTest extends TestCase
|
||||
{
|
||||
User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Skywalker', 'email' => 'luke@jedis.org']);
|
||||
|
||||
Passport::actingAs(User::factory()->create());
|
||||
Passport::actingAs(User::factory()->manageContactInfo()->create());
|
||||
$response = $this->getJson(route('api.users.selectlist', ['search' => 'luke@jedis']))->assertOk();
|
||||
|
||||
$results = collect($response->json('results'));
|
||||
@@ -53,6 +53,18 @@ class UsersForSelectListTest extends TestCase
|
||||
$this->assertTrue($results->pluck('text')->contains(fn ($text) => str_contains($text, 'Luke')));
|
||||
}
|
||||
|
||||
public function test_users_cannot_be_searched_by_email_without_permission()
|
||||
{
|
||||
User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Skywalker', 'email' => 'luke@jedis.org']);
|
||||
|
||||
Passport::actingAs(User::factory()->create());
|
||||
$response = $this->getJson(route('api.users.selectlist', ['search' => 'luke@jedis']))->assertOk();
|
||||
|
||||
$results = collect($response->json('results'));
|
||||
|
||||
$this->assertEquals(0, $results->count());
|
||||
}
|
||||
|
||||
public function test_users_scoped_to_company_when_multiple_full_company_support_enabled()
|
||||
{
|
||||
$this->settings->enableMultipleFullCompanySupport();
|
||||
|
||||
@@ -28,7 +28,7 @@ class CreateUserTest extends TestCase
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$response = $this->actingAs(User::factory()->createUsers()->viewUsers()->create())
|
||||
$response = $this->actingAs(User::factory()->createUsers()->viewUsers()->manageContactInfo()->create())
|
||||
->from(route('users.index'))
|
||||
->post(route('users.store'), [
|
||||
'first_name' => 'Test First Name',
|
||||
@@ -63,7 +63,7 @@ class CreateUserTest extends TestCase
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$response = $this->actingAs(User::factory()->createUsers()->viewUsers()->create())
|
||||
$response = $this->actingAs(User::factory()->createUsers()->viewUsers()->manageContactInfo()->create())
|
||||
->from(route('users.index'))
|
||||
->post(route('users.store'), [
|
||||
'first_name' => 'Test First Name',
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Users\Ui;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\User;
|
||||
use League\Csv\Reader;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExportUsersCsvTest extends TestCase
|
||||
{
|
||||
public function test_department_and_department_manager_columns_are_aligned_in_user_export(): void
|
||||
{
|
||||
$actor = User::factory()->viewUsers()->create();
|
||||
$departmentManager = User::factory()->create();
|
||||
|
||||
$department = Department::factory()->create([
|
||||
'name' => 'CSV Department '.now()->timestamp,
|
||||
'manager_id' => $departmentManager->id,
|
||||
]);
|
||||
|
||||
$target = User::factory()->create([
|
||||
'username' => 'csv-user-'.now()->timestamp,
|
||||
'department_id' => $department->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($actor)
|
||||
->get(route('users.export'))
|
||||
->assertOk();
|
||||
|
||||
$departmentHeader = trans('general.department');
|
||||
$departmentManagerHeader = trans('admin/users/general.department_manager');
|
||||
$usernameHeader = trans('admin/users/table.username');
|
||||
|
||||
$csv = Reader::createFromString($response->streamedContent());
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
$rows = collect(iterator_to_array($csv->getRecords(), false));
|
||||
|
||||
$targetRow = $rows->firstWhere($usernameHeader, $target->username);
|
||||
|
||||
$this->assertNotNull($targetRow, 'Target user not found in CSV export.');
|
||||
$this->assertSame($department->name, $targetRow[$departmentHeader]);
|
||||
$this->assertSame($departmentManager->display_name, $targetRow[$departmentManagerHeader]);
|
||||
}
|
||||
|
||||
public function test_department_columns_are_empty_when_user_has_no_department(): void
|
||||
{
|
||||
$actor = User::factory()->viewUsers()->create();
|
||||
|
||||
$target = User::factory()->create([
|
||||
'username' => 'csv-nodept-'.now()->timestamp,
|
||||
'department_id' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($actor)
|
||||
->get(route('users.export'))
|
||||
->assertOk();
|
||||
|
||||
$departmentHeader = trans('general.department');
|
||||
$departmentManagerHeader = trans('admin/users/general.department_manager');
|
||||
$usernameHeader = trans('admin/users/table.username');
|
||||
|
||||
$csv = Reader::createFromString($response->streamedContent());
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
$rows = collect(iterator_to_array($csv->getRecords(), false));
|
||||
|
||||
$targetRow = $rows->firstWhere($usernameHeader, $target->username);
|
||||
|
||||
$this->assertNotNull($targetRow, 'Target user not found in CSV export.');
|
||||
$this->assertSame('', $targetRow[$departmentHeader]);
|
||||
$this->assertSame('', $targetRow[$departmentManagerHeader]);
|
||||
}
|
||||
|
||||
public function test_header_row_is_not_repeated_when_export_spans_multiple_chunks(): void
|
||||
{
|
||||
$actor = User::factory()->viewUsers()->create();
|
||||
User::factory()->count(505)->create();
|
||||
|
||||
$response = $this->actingAs($actor)
|
||||
->get(route('users.export'))
|
||||
->assertOk();
|
||||
|
||||
$rows = collect(Reader::createFromString($response->streamedContent())->getRecords());
|
||||
|
||||
$headerId = strtolower(trans('general.id'));
|
||||
$headerCompany = trans('admin/companies/table.title');
|
||||
|
||||
$headerOccurrences = $rows->filter(static function (array $row) use ($headerId, $headerCompany): bool {
|
||||
return (($row[0] ?? null) === $headerId) && (($row[1] ?? null) === $headerCompany);
|
||||
})->count();
|
||||
|
||||
$this->assertSame(1, $headerOccurrences);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,36 @@ class UpdateUserTest extends TestCase
|
||||
$this->assertNotTrue(Hash::check('super-secret-new-password', $superuser->password), $superuser->refresh()->password);
|
||||
}
|
||||
|
||||
public function test_manage_contact_info_without_auth_field_access_cannot_change_admin_email()
|
||||
{
|
||||
$editingUser = User::factory()->editUsers()->manageContactInfo()->create(['activated' => true]);
|
||||
$admin = User::factory()->admin()->create(['email' => 'admin@example.org', 'activated' => true]);
|
||||
|
||||
$this->actingAs($editingUser)
|
||||
->put(route('users.update', $admin), [
|
||||
'first_name' => $admin->first_name,
|
||||
'last_name' => $admin->last_name,
|
||||
'email' => 'hijack-admin@example.org',
|
||||
]);
|
||||
|
||||
$this->assertSame('admin@example.org', $admin->refresh()->email);
|
||||
}
|
||||
|
||||
public function test_manage_contact_info_without_auth_field_access_cannot_change_superadmin_email()
|
||||
{
|
||||
$editingUser = User::factory()->editUsers()->manageContactInfo()->create(['activated' => true]);
|
||||
$superuser = User::factory()->superuser()->create(['email' => 'superuser@example.org', 'activated' => true]);
|
||||
|
||||
$this->actingAs($editingUser)
|
||||
->put(route('users.update', $superuser), [
|
||||
'first_name' => $superuser->first_name,
|
||||
'last_name' => $superuser->last_name,
|
||||
'email' => 'hijack-superuser@example.org',
|
||||
]);
|
||||
|
||||
$this->assertSame('superuser@example.org', $superuser->refresh()->email);
|
||||
}
|
||||
|
||||
public function test_multi_company_user_cannot_be_moved_if_has_asset_in_different_company()
|
||||
{
|
||||
$this->settings->enableMultipleFullCompanySupport();
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Users\Ui;
|
||||
use App\Models\Company;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ViewUserTest extends TestCase
|
||||
@@ -80,4 +81,99 @@ class ViewUserTest extends TestCase
|
||||
->assertSee('reports.view')
|
||||
->assertSee('label-danger', false);
|
||||
}
|
||||
|
||||
public function test_hides_email_when_actor_lacks_contact_permission(): void
|
||||
{
|
||||
$actor = User::factory()->viewUsers()->create();
|
||||
$target = User::factory()->create(['email' => 'hidden@example.com']);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertDontSee('hidden@example.com');
|
||||
}
|
||||
|
||||
public function test_shows_email_when_actor_has_contact_permission(): void
|
||||
{
|
||||
$actor = User::factory()->viewUsers()->manageContactInfo()->create();
|
||||
$target = User::factory()->create(['email' => 'visible@example.com']);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertSee('visible@example.com');
|
||||
}
|
||||
|
||||
public function test_superuser_can_see_email_on_user_view(): void
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['email' => 'super@example.com']);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertSee('super@example.com');
|
||||
}
|
||||
|
||||
public function test_hides_website_when_actor_lacks_contact_permission(): void
|
||||
{
|
||||
$actor = User::factory()->viewUsers()->create();
|
||||
$target = User::factory()->create(['website' => 'https://hidden.example.com']);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertDontSee('hidden.example.com');
|
||||
}
|
||||
|
||||
public function test_superuser_can_see_website_on_user_view(): void
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['website' => 'https://super.example.com']);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertSee('super.example.com');
|
||||
}
|
||||
|
||||
public function test_hides_map_when_actor_lacks_contact_permission(): void
|
||||
{
|
||||
Config::set('services.google.maps_api_key', 'fake-map-key');
|
||||
|
||||
$actor = User::factory()->viewUsers()->create();
|
||||
$target = User::factory()->create([
|
||||
'address' => '123 Hidden St',
|
||||
'city' => 'Nowhere',
|
||||
'state' => 'CA',
|
||||
'country' => 'US',
|
||||
'zip' => '90001',
|
||||
]);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertDontSee('maps.googleapis.com/maps/api/staticmap')
|
||||
->assertDontSee('maps.google.com/?q=');
|
||||
}
|
||||
|
||||
public function test_shows_map_when_actor_has_contact_permission(): void
|
||||
{
|
||||
Config::set('services.google.maps_api_key', 'fake-map-key');
|
||||
|
||||
$actor = User::factory()->viewUsers()->manageContactInfo()->create();
|
||||
$target = User::factory()->create([
|
||||
'address' => '500 Visible Ave',
|
||||
'city' => 'Somewhere',
|
||||
'state' => 'WA',
|
||||
'country' => 'US',
|
||||
'zip' => '98052',
|
||||
]);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertSee('maps.googleapis.com/maps/api/staticmap')
|
||||
->assertSee('maps.google.com/?q=');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user