Compare commits

...

31 Commits

Author SHA1 Message Date
snipe 0d895b3d33 Pint 2026-04-17 18:37:46 +01:00
snipe 1a5e5567ec Nicer changed column for CSV export 2026-04-17 18:37:31 +01:00
snipe bfd155ec2e Pint 2026-04-17 18:26:50 +01:00
snipe 236d5330e5 Account for missing audit_interval 2026-04-17 18:26:43 +01:00
snipe c95306cd3e More tests 2026-04-17 18:07:30 +01:00
snipe 018fa3e8a0 Added regression tests 2026-04-17 17:49:22 +01:00
snipe 0103aaffbf Show an warning on the livewire importer if the user doesn’t have perrmission to edit censitive fields 2026-04-17 17:40:52 +01:00
snipe abc6386a71 Fixed user export header repeating 2026-04-17 17:37:32 +01:00
snipe c37556bc04 Added tests 2026-04-17 17:37:19 +01:00
snipe d5843f367d Pint 2026-04-17 17:23:22 +01:00
snipe 49e4fe7081 Updated searchable trait 2026-04-17 17:22:09 +01:00
snipe 5fca750402 Pint 2026-04-17 17:14:58 +01:00
snipe f5db2e0056 Added url attribute for user 2026-04-17 17:14:46 +01:00
snipe 26f0312094 Use the display_email attribute so we can hide it if the user doesn’t have permission 2026-04-17 17:02:53 +01:00
snipe ca797c4b4e Fixed flaky audit API test 2026-04-17 17:02:12 +01:00
snipe a013bc0660 Added test 2026-04-17 17:01:59 +01:00
snipe 5696481ba9 Whoops - missed dept name 2026-04-17 17:01:29 +01:00
snipe 79183b5ce5 Updated text 2026-04-17 17:01:09 +01:00
snipe 72d33bd371 Pint 2026-04-17 16:50:16 +01:00
snipe 752b5f7a5f Added test for user CSV export 2026-04-17 16:50:08 +01:00
snipe 0d04bcc7f6 Added displayEmail attribute to show/hide email on the fly based on permissions 2026-04-17 16:42:43 +01:00
snipe 8fec8b83e7 Added a few more tests 2026-04-17 16:42:03 +01:00
snipe 7ab18d0e8f Pint 2026-04-17 15:55:27 +01:00
snipe d35e510468 Only include the changed permissions in the log meta 2026-04-17 15:55:19 +01:00
snipe 30f9e89d4a Pint 2026-04-17 15:28:43 +01:00
snipe b6f781c39e Fixed gate signature 2026-04-17 15:28:34 +01:00
snipe 8c6bcab2eb Added factory for permission 2026-04-17 15:04:49 +01:00
snipe d38253523b Pint 2026-04-17 15:04:35 +01:00
snipe 7dfd5bd8a5 Fixed typo 2026-04-17 15:04:26 +01:00
snipe e0cc7bba4f Pinty pint pint 2026-04-17 14:58:16 +01:00
snipe 88714badec Apply changes from previous PR 2026-04-17 14:58:07 +01:00
39 changed files with 1645 additions and 182 deletions
+97 -70
View File
@@ -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()),
));
}
+14 -2
View File
@@ -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;
+140 -56
View File
@@ -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);
}
+118 -2
View File
@@ -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);
}
}
+20 -9
View File
@@ -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),
+26
View File
@@ -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);
}
}
+11
View File
@@ -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()
{
+9
View File
@@ -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
*
+11
View File
@@ -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()
{
+35
View File
@@ -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
View File
@@ -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.'%')
+70
View File
@@ -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
+5
View File
@@ -15,4 +15,9 @@ class UserPolicy extends SnipePermissionsPolicy
{
return $user->hasAccess($this->columnName().'.files');
}
public function manageContactInfo(User $user)
{
return $user->hasAccess('users.contact');
}
}
+22 -10
View File
@@ -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);
}
+4
View File
@@ -251,6 +251,10 @@ return [
'permission' => 'users.delete',
'display' => true,
],
[
'permission' => 'users.contact',
'display' => true,
],
[
'permission' => 'users.files',
'display' => true,
+5
View File
@@ -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']);
@@ -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.',
];
+6 -1
View File
@@ -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>
+34 -2
View File
@@ -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') }}
+19 -14
View File
@@ -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')
+3 -2
View File
@@ -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;
}
}
+130
View File
@@ -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'));
}
}
+23
View File
@@ -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('&quot;', $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();
+2 -2
View File
@@ -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);
}
}
+30
View File
@@ -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();
+96
View File
@@ -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=');
}
}