3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 03:06:23 +00:00

Merge pull request #19439 from grokability/ldap-sync-mapping

LDAP: fixed typos, deliver the summary in a table in cli mode
This commit is contained in:
snipe
2026-08-07 16:02:39 +01:00
committed by GitHub
17 changed files with 477 additions and 456 deletions

View File

@ -3,7 +3,6 @@
namespace App\Console\Commands;
use App\Models\Asset;
use App\Models\Department;
use App\Models\Group;
use App\Models\Ldap;
use App\Models\Location;
@ -55,27 +54,12 @@ class LdapSync extends Command
ini_set('max_execution_time', env('LDAP_TIME_LIM', 600)); // 600 seconds = 10 minutes
ini_set('memory_limit', env('LDAP_MEM_LIM', '500M'));
// Map the LDAP attributes to the Snipe-IT user fields.
$ldap_map = [
'username' => Setting::getSettings()->ldap_username_field,
'last_name' => Setting::getSettings()->ldap_lname_field,
'first_name' => Setting::getSettings()->ldap_fname_field,
'active_flag' => Setting::getSettings()->ldap_active_flag,
'emp_num' => Setting::getSettings()->ldap_emp_num,
'email' => Setting::getSettings()->ldap_email,
'phone' => Setting::getSettings()->ldap_phone_field,
'mobile' => Setting::getSettings()->ldap_mobile,
'jobtitle' => Setting::getSettings()->ldap_jobtitle,
'address' => Setting::getSettings()->ldap_address,
'city' => Setting::getSettings()->ldap_city,
'state' => Setting::getSettings()->ldap_state,
'zip' => Setting::getSettings()->ldap_zip,
'country' => Setting::getSettings()->ldap_country,
'location' => Setting::getSettings()->ldap_location,
'dept' => Setting::getSettings()->ldap_dept,
'manager' => Setting::getSettings()->ldap_manager,
'display_name' => Setting::getSettings()->ldap_display_name,
];
// Single source of truth for internal-key => LDAP-attribute-name
// lives on the Ldap model so parseAndMapLdapAttributes and this
// command can't drift. Used here for the LDAP query attribute
// list plus a handful of specific-lookup gates (activated,
// manager, location, username) that only LdapSync needs.
$ldap_map = Ldap::attributeMap();
$ldap_default_group = Setting::getSettings()->ldap_default_group;
$search_base = Setting::getSettings()->ldap_base_dn;
@ -133,7 +117,7 @@ class LdapSync extends Command
*/
$attributes = array_values(array_filter($ldap_map));
if (Setting::getSettings()->is_ad === 1 && is_null($ldap_map['active_flag'])) {
if (Setting::getSettings()->is_ad === 1 && is_null($ldap_map['activated'])) {
$attributes[] = 'useraccountcontrol';
}
@ -243,37 +227,15 @@ class LdapSync extends Command
// Assign the mapped LDAP attributes for each user to the Snipe-IT user fields
for ($i = 0; $i < $results['count']; $i++) {
$item = [];
$item['username'] = $results[$i][$ldap_map['username']][0] ?? null;
$item['display_name'] = $results[$i][$ldap_map['display_name']][0] ?? null;
$item['employee_number'] = $results[$i][$ldap_map['emp_num']][0] ?? null;
$item['lastname'] = $results[$i][$ldap_map['last_name']][0] ?? null;
$item['firstname'] = $results[$i][$ldap_map['first_name']][0] ?? null;
$item['email'] = $results[$i][$ldap_map['email']][0] ?? null;
// parseAndMapLdapAttributes is the shared parser used by the
// first-login create path too, so the two flows can't drift
// on field names / lookup shape. The two OU-override keys
// (ldap_location_override, location_id) are LdapSync-only,
// injected earlier by the OU sweep at line 191 or so, so we
// stitch them back on here.
$item = Ldap::parseAndMapLdapAttributes($results[$i]);
$item['ldap_location_override'] = $results[$i]['ldap_location_override'] ?? null;
$item['location_id'] = $results[$i]['location_id'] ?? null;
$item['telephone'] = $results[$i][$ldap_map['phone']][0] ?? null;
$item['mobile'] = $results[$i][$ldap_map['mobile']][0] ?? null;
$item['jobtitle'] = $results[$i][$ldap_map['jobtitle']][0] ?? null;
$item['address'] = $results[$i][$ldap_map['address']][0] ?? null;
$item['city'] = $results[$i][$ldap_map['city']][0] ?? null;
$item['state'] = $results[$i][$ldap_map['state']][0] ?? null;
$item['country'] = $results[$i][$ldap_map['country']][0] ?? null;
$item['zip'] = $results[$i][$ldap_map['zip']][0] ?? null;
$item['department'] = $results[$i][$ldap_map['dept']][0] ?? null;
$item['manager'] = $results[$i][$ldap_map['manager']][0] ?? null;
$item['location'] = $results[$i][$ldap_map['location']][0] ?? null;
$location = $default_location; // initially, set '$location' to the default_location (which may just be null)
// ONLY if you are using the "ldap_location" option *AND* you have an actual result
if ($ldap_map['location'] && $item['location']) {
$location = Location::firstOrCreate([
'name' => $item['location'],
]);
}
$department = Department::firstOrCreate([
'name' => $item['department'],
]);
$user = User::withTrashed()->where('username', $item['username'])->first();
if (! empty($item['username'])) {
@ -294,55 +256,14 @@ class LdapSync extends Command
$item['createorupdate'] = 'created';
}
// If a sync option is not filled in on the LDAP settings don't populate the user field
if ($ldap_map['username'] != null) {
$user->username = $item['username'];
}
if ($ldap_map['display_name'] != null) {
$user->display_name = $item['display_name'];
}
if ($ldap_map['last_name'] != null) {
$user->last_name = $item['lastname'];
}
if ($ldap_map['first_name'] != null) {
$user->first_name = $item['firstname'];
}
if ($ldap_map['emp_num'] != null) {
$user->employee_num = e($item['employee_number']);
}
if ($ldap_map['email'] != null) {
$user->email = $item['email'];
}
if ($ldap_map['phone'] != null) {
$user->phone = $item['telephone'];
}
if ($ldap_map['mobile'] != null) {
$user->mobile = $item['mobile'];
}
if ($ldap_map['jobtitle'] != null) {
$user->jobtitle = $item['jobtitle'];
}
if ($ldap_map['address'] != null) {
$user->address = $item['address'];
}
if ($ldap_map['city'] != null) {
$user->city = $item['city'];
}
if ($ldap_map['state'] != null) {
$user->state = $item['state'];
}
if ($ldap_map['country'] != null) {
$user->country = $item['country'];
}
if ($ldap_map['zip'] != null) {
$user->zip = $item['zip'];
}
if ($ldap_map['dept'] != null) {
$user->department_id = $department->id;
}
if ($ldap_map['location'] != null) {
$user->location_id = $location?->id;
}
// Shared field-write path with the first-login create flow.
// Handles every mapped scalar field, plus Department and
// Location firstOrCreate for the LDAP-derived values. The
// three LdapSync-only concerns (manager LDAP re-query,
// activated / UAC, OU location override) are handled
// inline below because they don't apply to the first-login
// path.
Ldap::applyLdapAttributesToUser($user, $item);
if ($ldap_map['manager'] != null) {
if ($item['manager'] != null) {
@ -394,10 +315,10 @@ class LdapSync extends Command
}
// Sync activated state for Active Directory.
if (! empty($ldap_map['active_flag'])) { // IF we have an 'active' flag set....
if (! empty($ldap_map['activated'])) { // IF we have an 'active' flag set....
// ....then *most* things that are truthy will activate the user. Anything falsey will deactivate them.
// (Specifically, we don't handle a value of '0.0' correctly)
$raw_value = @$results[$i][$ldap_map['active_flag']][0];
$raw_value = @$results[$i][$ldap_map['activated']][0];
$filter_var = filter_var($raw_value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$boolean_cast = (bool) $raw_value;
@ -449,19 +370,28 @@ class LdapSync extends Command
} /* implied 'else' here - leave the $user->activated flag alone. Newly-created accounts will be active.
already-existing accounts will be however the administrator has set them */
// Location resolution: applyLdapAttributesToUser above has
// already written location_id from the LDAP payload when a
// value was present. This block layers on the two overrides
// it doesn't know about: the OU-based override wins over
// everything when set, and the --location CLI flag fills in
// when neither the OU override nor an LDAP-derived location
// applied to this run.
$ldapProvidedLocation = $ldap_map['location'] !== null && $item['location'] !== '';
if ($item['ldap_location_override'] == true) {
$user->location_id = $item['location_id'];
} elseif ((isset($location)) && (! empty($location))) {
if ((is_array($location)) && (array_key_exists('id', $location))) {
$user->location_id = $location['id'];
} elseif (is_object($location)) {
$user->location_id = $location->id; // THIS is the magic line, this should do it.
}
} elseif (! $ldapProvidedLocation && ! empty($default_location)) {
$user->location_id = is_array($default_location)
? $default_location['id']
: $default_location->id;
}
// TODO - should we be NULLING locations if $location is really `null`, and that's what we came up with?
// will that conflict with any overriding setting that the user set? Like, if they moved someone from
// the 'null' location to somewhere, we wouldn't want to try to override that, right?
$location = null;
// TODO - should we be NULLING locations when neither the OU
// override, the LDAP payload, nor --location produced a
// location for this user? Currently we leave whatever they
// had, matching the pre-refactor behavior. Changing that
// could clobber a location an admin set by hand.
$user->ldap_import = 1;
$errors = '';
@ -505,13 +435,18 @@ class LdapSync extends Command
$missing_ldap_users = $missing_ldap_users->get();
foreach ($missing_ldap_users as $missing_user) {
$is_deletable = $this->isUserDeletable($missing_user);
// Match the rule a manual "delete user" click uses. We
// can't call User::isDeletable() directly here because
// it wraps a Gate::allows('delete', $user) check that
// needs an authenticated web-session user, and this
// command runs from cron with no such user
$is_deletable = $missing_user->hasNoAssignmentBlockers();
$missing_item = [
'id' => $missing_user->id,
'username' => $missing_user->username,
'firstname' => $missing_user->first_name,
'lastname' => $missing_user->last_name,
'first_name' => $missing_user->first_name,
'last_name' => $missing_user->last_name,
'email' => $missing_user->email,
'createorupdate' => 'skipped',
'status' => 'info',
@ -530,16 +465,19 @@ class LdapSync extends Command
}
}
if ($this->option('summary')) {
for ($x = 0; $x < count($summary); $x++) {
if ($summary[$x]['status'] == 'error') {
$this->error('ERROR: '.$summary[$x]['firstname'].' '.$summary[$x]['lastname'].' (username: '.$summary[$x]['username'].') was not imported: '.$summary[$x]['note']);
} else {
$this->info('User '.$summary[$x]['firstname'].' '.$summary[$x]['lastname'].' (username: '.$summary[$x]['username'].') was '.strtoupper($summary[$x]['createorupdate']).'.');
}
}
$rows = array_map(fn ($row) => [
$row['username'] ?? '',
trim(($row['first_name'] ?? '').' '.($row['last_name'] ?? '')),
strtoupper($row['createorupdate'] ?? ''),
strtoupper($row['status'] ?? ''),
$row['note'] ?? '',
], $summary);
$this->table(
['Username', 'Name', 'Action', 'Status', 'Note'],
$rows,
);
} elseif ($this->option('json_summary')) {
$json_summary = ['error' => false, 'error_message' => '', 'summary' => $summary]; // hardcoding the error to false and the error_message to blank seems a bit weird
$this->info(json_encode($json_summary));
@ -547,23 +485,4 @@ class LdapSync extends Command
return $summary;
}
}
/**
* Checks if the user is deletable without gate check
*
* A user is considered deletable if they have no associated assets, accessories, licenses, consumables, managed users, or managed locations.
*
* @param User $user The user to check
*
* @return bool True if the user is deletable, false otherwise
*/
private function isUserDeletable(User $user): bool
{
return (($user->assets_count ?? $user->assets()->count()) === 0)
&& (($user->accessories_count ?? $user->accessories()->count()) === 0)
&& (($user->licenses_count ?? $user->licenses()->count()) === 0)
&& (($user->consumables_count ?? $user->consumables()->count()) === 0)
&& (($user->manages_users_count ?? $user->managesUsers()->count()) === 0)
&& (($user->manages_locations_count ?? $user->managedLocations()->count()) === 0);
}
}

View File

@ -56,7 +56,12 @@ class ResetDemoSettings extends Command
$settings->label2_2d_type = 'QRCODE';
$settings->default_currency = 'USD';
$settings->brand = 2;
$settings->ldap_enabled = 0;
// Enabled so the wizard's return-visitor branch unlocks all 5
// steps for demo visitors and they can jump straight to the
// step-3 Test Find User preview against the seeded Forumsys
// config. Safe on the demo because LoginController skips the
// LDAP auth branch when config('app.lock_passwords') is on.
$settings->ldap_enabled = '1';
$settings->full_multiple_companies_support = 0;
$settings->label2_1d_type = 'C128';
$settings->email_domain = 'snipeitapp.com';

View File

@ -43,31 +43,30 @@ class SettingsController extends Controller
'message' => 'Successfully connected to LDAP server.',
];
// Shape is driven by Ldap::parseAndMapLdapAttributes so
// this endpoint stays in sync with the sync command and
// the first-login create path. Blank fields collapse to
// null in the JSON so the JS side's `?? NULL` fallback
// renders "NULL" for missing values.
$users = collect(Ldap::findLdapUsers(null, 10))->filter(function ($value, $key) {
return is_int($key);
})->slice(0, 10)->map(function ($item) use ($settings) {
return (object) [
'username' => $item[$settings['ldap_username_field']][0] ?? null,
'display_name' => $item[$settings['ldap_display_name']][0] ?? null,
'employee_number' => $item[$settings['ldap_emp_num']][0] ?? null,
'lastname' => $item[$settings['ldap_lname_field']][0] ?? null,
'firstname' => $item[$settings['ldap_fname_field']][0] ?? null,
'email' => $item[$settings['ldap_email']][0] ?? null,
'phone' => $item[$settings['ldap_phone_field']][0] ?? null,
'mobile' => $item[$settings['ldap_mobile']][0] ?? null,
'jobtitle' => $item[$settings['ldap_jobtitle']][0] ?? null,
'department' => $item[$settings['ldap_department']][0] ?? null,
'manager' => $item[$settings['ldap_manager']][0] ?? null,
'address' => $item[$settings['ldap_address']][0] ?? null,
'city' => $item[$settings['ldap_city']][0] ?? null,
'state' => $item[$settings['ldap_state']][0] ?? null,
'zip' => $item[$settings['ldap_zip']][0] ?? null,
'country' => $item[$settings['ldap_country']][0] ?? null,
'location' => $item[$settings['ldap_location']][0] ?? null,
];
})->slice(0, 10)->map(function ($item) {
$mapped = Ldap::parseAndMapLdapAttributes($item);
return (object) array_map(fn ($value) => $value === '' ? null : $value, $mapped);
});
if ($users->count() > 0) {
// `fields` is the ordered internal_key => translated
// label map the JS iterates to build the results
// table's header + per-row cells. Same shape drives
// the LDAP wizard's step-3 preview, so both stay
// consistent as attributeMap() grows.
$labels = Ldap::attributeLabels();
$fields = collect(array_keys(Ldap::parseAndMapLdapAttributes([])))
->mapWithKeys(fn ($key) => [$key => $labels[$key] ?? $key])
->all();
$message['user_sync'] = [
'fields' => $fields,
'users' => $users,
];
} else {

View File

@ -1043,7 +1043,13 @@ class UsersController extends Controller
*/
public function syncLdapUsers(Request $request)
{
$this->authorize('update', User::class);
// Superuser-only: a bulk LDAP sync surfaces users from across
// the entire directory (all companies, all OUs), so anyone with
// "users.edit" but no full-directory access shouldn't be able
// to run it or read the summary that lists them.
if (! auth()->user()?->isSuperUser()) {
abort(403);
}
// Call Artisan LDAP import command.
Artisan::call('snipeit:ldap-sync', ['--location_id' => $request->input('location_id'), '--json_summary' => true]);

View File

@ -300,7 +300,7 @@ class LoginController extends Controller
return redirect()->back()->withInput()->withErrors($validator);
}
// Set the custom lockout attempts from the env and sett the custom lockout throttle from the env.
// Set the custom lockout attempts from the env and set the custom lockout throttle from the env.
// We divide decayMinutes by 60 here to get minutes, since Laravel changed the default from minutes
// to seconds, and we don't want to break limits on existing systems
$this->maxAttempts = config('auth.passwords.users.throttle.max_attempts');
@ -314,8 +314,12 @@ class LoginController extends Controller
$user = null;
// Should we even check for LDAP users?
if (Setting::getSettings()->ldap_enabled) { // avoid hitting the $this->ldap
// Should we even check for LDAP users? Skip LDAP entirely when
// the app is in demo mode. The LDAP wizard's
// demo seed points at Forumsys as a reference config for
// visitors to click through, we don't want the login form to
// actually try to bind against it on every demo sign-in.
if (Setting::getSettings()->ldap_enabled && !config('app.lock_passwords')) { // avoid hitting the $this->ldap
Log::debug('LDAP is enabled.');
try {
Log::debug('Attempting to log user in by LDAP authentication.');

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers\Users;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@ -28,8 +27,13 @@ class LDAPImportController extends Controller
*/
public function create()
{
// I guess this prolly oughtta... I dunno. Do something?
$this->authorize('update', User::class);
// Superuser-only: a bulk LDAP sync surfaces users from across
// the entire directory (all companies, all OUs), so anyone with
// "users.edit" but no full-directory access shouldn't be able
// to run it or read the summary that lists them.
if (! auth()->user()?->isSuperUser()) {
abort(403);
}
try {
// $this->ldap->connect(); I don't think this actually exists in LdapAd.php, and we don't really 'persist' LDAP connections anyways...right?
} catch (\Exception $e) {
@ -52,7 +56,10 @@ class LDAPImportController extends Controller
*/
public function store(Request $request)
{
$this->authorize('update', User::class);
// See create() for the superuser-only rationale.
if (! auth()->user()?->isSuperUser()) {
abort(403);
}
// Call Artisan LDAP import command.
Artisan::call('snipeit:ldap-sync', ['--location_id' => $request->input('location_id'), '--json_summary' => true]);

View File

@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Models\Ldap;
use App\Models\Setting;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Gate;
@ -77,6 +78,24 @@ class LdapSettings extends Component
// finishWizard so back-nav doesn't retrigger the animation.
public ?int $justCompletedStep = null;
// Read-only lock. Set from config('app.lock_passwords') in mount()
// and blade uses it to render every wire:model input with the
// `readonly` / `disabled` attribute so demo visitors can't retype
// real LDAP creds into the wizard. Server-side enforcement lives
// in updated(), which reverts any prop mutation back to the
// persisted Setting values (defense against a caller that fakes
// wire:model updates around the disabled UI).
public bool $isReadOnly = false;
// Properties that stay editable even when isReadOnly is on. The
// sample-username field on step 3 has to remain writable so the
// Test Find User preview still works, which is the one wizard
// interaction we do want demo visitors to exercise.
private const READ_ONLY_ALLOWED_PROPS = [
'currentStep',
'test_sample_username',
];
// Step 1: Connection
public bool $ldap_enabled = false;
@ -184,6 +203,7 @@ class LdapSettings extends Component
public function mount(): void
{
$this->isReadOnly = (bool) config('app.lock_passwords');
$this->hydrateFromPersisted();
// Restore in-flight wizard progress from the session so a page
@ -208,6 +228,17 @@ class LdapSettings extends Component
}
}
// Demo mode unlocks the wizard independent of ldap_enabled.
// The save/advance methods are gated shut by lock_passwords
// so a visitor with ldap_enabled=false would otherwise be
// trapped on step 1 with no way to reach the Test Find User
// preview on step 3. Unlocking the stepper here lets them
// jump to any step. Fields stay locked via isReadOnly /
// updated() enforcement.
if ($this->isReadOnly) {
$this->highestStepReached = 5;
}
// Clamp against total step count in case a session pointer
// was seeded when the wizard had a different step layout.
$this->highestStepReached = min($this->highestStepReached, 5);
@ -312,7 +343,18 @@ class LdapSettings extends Component
public function saveAndAdvance()
{
// Demo mode: nothing to save (isReadOnly + updated() lock the
// fields to seeded values), but visitors still want to walk
// the wizard forward step by step to see each screen. Skip
// validation / network test / persist and just advance. The
// per-step Test Bind / Test Find User buttons on individual
// steps remain available for anyone who wants to fire a live
// request against the seeded Forumsys config.
if (config('app.lock_passwords')) {
if ($this->currentStep < 5) {
$this->goToStep($this->currentStep + 1);
}
return null;
}
@ -770,38 +812,6 @@ class LdapSettings extends Component
// === Step 3: Attribute mapping ========================================= =============================
/**
* Snipe-IT field name → LDAP-attribute-name Livewire property. Used
* both by the preview-table render and by any future sync-side code
* that wants a canonical map of "what field goes where." Order here
* defines the order in the preview table.
*
* Any new mappings we create would need to go here too.
*/
protected function attributeMap(): array
{
return [
'username' => $this->ldap_username_field,
'first_name' => $this->ldap_fname_field,
'last_name' => $this->ldap_lname_field,
'display_name' => $this->ldap_display_name,
'email' => $this->ldap_email,
'employee_num' => $this->ldap_emp_num,
'phone' => $this->ldap_phone_field,
'mobile' => $this->ldap_mobile,
'job_title' => $this->ldap_jobtitle,
'manager' => $this->ldap_manager,
'department' => $this->ldap_dept,
'address' => $this->ldap_address,
'city' => $this->ldap_city,
'state' => $this->ldap_state,
'zip' => $this->ldap_zip,
'country' => $this->ldap_country,
'location' => $this->ldap_location,
'active_flag' => $this->ldap_active_flag,
];
}
protected function saveStep3(): void
{
$this->validate($this->step3SyntaxRules(), attributes: $this->step3SyntaxAttributes());
@ -923,7 +933,7 @@ class LdapSettings extends Component
// whole entry when we only care about a handful.
$requestedAttrs = array_values(array_filter(array_map(
fn ($attr) => trim(strtolower((string) $attr)),
$this->attributeMap(),
Ldap::attributeMap($this),
)));
$searchResult = @ldap_search($conn, $this->ldap_basedn, $lookupFilter, $requestedAttrs);
@ -986,10 +996,12 @@ class LdapSettings extends Component
// muted). Attribute names are compared lowercase. LDAP is
// case-insensitive on attribute names.
$preview = [];
foreach ($this->attributeMap() as $snipeField => $ldapAttr) {
$labels = Ldap::attributeLabels();
foreach (Ldap::attributeMap($this) as $snipeField => $ldapAttr) {
$label = $labels[$snipeField] ?? $snipeField;
$ldapAttrLower = trim(strtolower((string) $ldapAttr));
if ($ldapAttrLower === '') {
$preview[$snipeField] = ['attr' => null, 'value' => null];
$preview[$snipeField] = ['label' => $label, 'attr' => null, 'value' => null];
continue;
}
@ -997,7 +1009,7 @@ class LdapSettings extends Component
if (isset($attributes[$ldapAttrLower][0])) {
$value = $attributes[$ldapAttrLower][0];
}
$preview[$snipeField] = ['attr' => $ldapAttr, 'value' => $value];
$preview[$snipeField] = ['label' => $label, 'attr' => $ldapAttr, 'value' => $value];
}
$this->step3TestDn = (string) $dn;
@ -1394,6 +1406,20 @@ class LdapSettings extends Component
public function updated(string $property): void
{
// Read-only lock: in demo mode any mutation to a persisted
// LDAP config field gets reverted to the seeded Setting value
// before the rest of the updated() logic runs. Server-side
// enforcement, so a client that fakes wire:model updates
// around the UI's readonly / disabled attributes still can't
// get modified creds into a Test Bind / Test Find User call.
// test_sample_username stays writable so the Look Up preview
// still works.
if ($this->isReadOnly && ! in_array($property, self::READ_ONLY_ALLOWED_PROPS, true)) {
$this->hydrateFromPersisted();
return;
}
// Trim string values on assignment so pasted-with-whitespace
// inputs get normalized both in the visible field and in the
// saved config. Without this a leading space on ldap_server

View File

@ -60,8 +60,6 @@ class Ldap extends Model
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @return connection
*/
public static function connectToLdap()
{
@ -180,16 +178,14 @@ class Ldap extends Model
}
/**
* Binds/authenticates the user to LDAP, and returns their attributes.
* Binds/authenticates the user to LDAP, and returns their attributes
* (lowercase-keyed) on success or false when the bind or search fails.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @param bool|false $user
* @return bool true if the username and/or password provided are valid
* false if the username and/or password provided are invalid
* array of ldap_attributes if $user is true
* @return array|false
*/
public static function findAndBindUserLdap($username, $password)
{
@ -311,52 +307,104 @@ class Ldap extends Model
* @param $ldapatttibutes
* @return array|bool
*/
/**
* Single source of truth for the LDAP-attribute mapping. Internal
* key (used across parseAndMapLdapAttributes' $item, the User field
* writes in applyLdapAttributesToUser, and LdapSync's specific
* lookups) => LDAP attribute name.
*
* Keys mirror the User model's column names (jobtitle,
* employee_num, first_name, etc.) so downstream code can walk this
* map and write straight onto a User without translation.
*
* `$source` defaults to the persisted Setting model so backend
* callers (LdapSync, parseAndMapLdapAttributes, applyLdapAttributesToUser)
* see saved values. The LDAP wizard Livewire component passes
* `$this` so its live preview reflects in-flight form edits before
* they're written to Settings. Any object exposing the same
* `ldap_*` properties works.
*
* @param object|null $source Setting-shaped object; defaults to Setting::getSettings()
* @return array<string, ?string>
*/
public static function attributeMap(?object $source = null): array
{
$source ??= Setting::getSettings();
return [
'username' => $source->ldap_username_field,
'first_name' => $source->ldap_fname_field,
'last_name' => $source->ldap_lname_field,
'employee_num' => $source->ldap_emp_num,
'display_name' => $source->ldap_display_name,
'email' => $source->ldap_email,
'phone' => $source->ldap_phone_field,
'mobile' => $source->ldap_mobile,
'jobtitle' => $source->ldap_jobtitle,
'address' => $source->ldap_address,
'city' => $source->ldap_city,
'state' => $source->ldap_state,
'zip' => $source->ldap_zip,
'country' => $source->ldap_country,
'department' => $source->ldap_dept,
'location' => $source->ldap_location,
'manager' => $source->ldap_manager,
// LdapSync-only: activated is consumed by the active-
// directory sync logic in the console command, which reads
// the mapped LDAP attribute (or AD's useraccountcontrol)
// and writes the resulting bool onto user.activated.
// parseAndMapLdapAttributes does not surface it because
// the first-login path has no use for it (the user just
// successfully bound to LDAP, they're active by definition).
'activated' => $source->ldap_active_flag,
];
}
/**
* Companion to attributeMap(): internal key => translated human
* label. Consumed by the LDAP wizard's step-3 preview table and by
* the settings-page ldaptest results table, so both render
* "Employee Number" / "Title" / etc. instead of the raw
* snake_case internal keys. Adding a new key to attributeMap()
* should be paired with an entry here so it shows up nicely in
* both places automatically.
*
* @return array<string, string>
*/
public static function attributeLabels(): array
{
return [
'username' => trans('general.username'),
'first_name' => trans('general.first_name'),
'last_name' => trans('general.last_name'),
'employee_num' => trans('general.employee_number'),
'display_name' => trans('admin/users/table.display_name'),
'email' => trans('general.email'),
'phone' => trans('general.phone'),
'mobile' => trans('admin/users/table.mobile'),
'jobtitle' => trans('admin/users/table.title'),
'address' => trans('general.address'),
'city' => trans('general.city'),
'state' => trans('general.state'),
'zip' => trans('general.zip'),
'country' => trans('general.country'),
'department' => trans('general.department'),
'location' => trans('general.location'),
'manager' => trans('admin/users/table.manager'),
'activated' => trans('admin/users/table.activated'),
];
}
public static function parseAndMapLdapAttributes($ldapattributes)
{
// Get LDAP attribute config. The settings column names here are
// the same ones LdapSync's $ldap_map reads, so this parser and
// the bulk-sync command see identical field lookups.
$settings = Setting::getSettings();
$ldap_result_username = $settings->ldap_username_field;
$ldap_result_emp_num = $settings->ldap_emp_num;
$ldap_result_last_name = $settings->ldap_lname_field;
$ldap_result_first_name = $settings->ldap_fname_field;
$ldap_result_display_name = $settings->ldap_display_name;
$ldap_result_email = $settings->ldap_email;
$ldap_result_phone = $settings->ldap_phone_field;
$ldap_result_mobile = $settings->ldap_mobile;
$ldap_result_jobtitle = $settings->ldap_jobtitle;
$ldap_result_address = $settings->ldap_address;
$ldap_result_city = $settings->ldap_city;
$ldap_result_state = $settings->ldap_state;
$ldap_result_zip = $settings->ldap_zip;
$ldap_result_country = $settings->ldap_country;
$ldap_result_location = $settings->ldap_location;
$ldap_result_dept = $settings->ldap_dept;
$ldap_result_manager = $settings->ldap_manager;
// Get LDAP user data. Kept in the same shape LdapSync's per-user
// $item array uses so the two paths stay comparable when this
// one grows.
$item = [];
$item['username'] = $ldapattributes[$ldap_result_username][0] ?? '';
$item['employee_number'] = $ldapattributes[$ldap_result_emp_num][0] ?? '';
$item['lastname'] = $ldapattributes[$ldap_result_last_name][0] ?? '';
$item['firstname'] = $ldapattributes[$ldap_result_first_name][0] ?? '';
$item['display_name'] = $ldapattributes[$ldap_result_display_name][0] ?? '';
$item['email'] = $ldapattributes[$ldap_result_email][0] ?? '';
$item['telephone'] = $ldapattributes[$ldap_result_phone][0] ?? '';
$item['mobile'] = $ldapattributes[$ldap_result_mobile][0] ?? '';
$item['jobtitle'] = $ldapattributes[$ldap_result_jobtitle][0] ?? '';
$item['address'] = $ldapattributes[$ldap_result_address][0] ?? '';
$item['city'] = $ldapattributes[$ldap_result_city][0] ?? '';
$item['state'] = $ldapattributes[$ldap_result_state][0] ?? '';
$item['zip'] = $ldapattributes[$ldap_result_zip][0] ?? '';
$item['country'] = $ldapattributes[$ldap_result_country][0] ?? '';
$item['department'] = $ldapattributes[$ldap_result_dept][0] ?? '';
$item['manager'] = $ldapattributes[$ldap_result_manager][0] ?? '';
$item['location'] = $ldapattributes[$ldap_result_location][0] ?? '';
$item['locale'] = app()->getLocale();
foreach (self::attributeMap() as $key => $ldapAttr) {
// activated is LdapSync's concern. See attributeMap().
if ($key === 'activated') {
continue;
}
$item[$key] = $ldapAttr ? ($ldapattributes[$ldapAttr][0] ?? '') : '';
}
return $item;
}
@ -381,53 +429,53 @@ class Ldap extends Model
*/
public static function applyLdapAttributesToUser(User $user, array $ldapAttr): void
{
$settings = Setting::getSettings();
$map = self::attributeMap();
// Always-written identity fields. These have no per-field gate
// because Snipe-IT considers username / first name / last name /
// email load-bearing for every user if a mapping's blank the
// email important for every user, if a mapping's blank the
// LDAP payload just gives us an empty string, matching the
// pre-fix behavior on the create path.
$user->username = $ldapAttr['username'];
$user->first_name = $ldapAttr['firstname'];
$user->last_name = $ldapAttr['lastname'];
$user->first_name = $ldapAttr['first_name'];
$user->last_name = $ldapAttr['last_name'];
$user->email = $ldapAttr['email'];
if ($settings->ldap_display_name != '') {
if ($map['display_name'] != '') {
$user->display_name = $ldapAttr['display_name'];
}
if ($settings->ldap_emp_num != '') {
$user->employee_num = e($ldapAttr['employee_number']);
if ($map['employee_num'] != '') {
$user->employee_num = e($ldapAttr['employee_num']);
}
if ($settings->ldap_phone_field != '') {
$user->phone = $ldapAttr['telephone'];
if ($map['phone'] != '') {
$user->phone = $ldapAttr['phone'];
}
if ($settings->ldap_mobile != '') {
if ($map['mobile'] != '') {
$user->mobile = $ldapAttr['mobile'];
}
if ($settings->ldap_jobtitle != '') {
if ($map['jobtitle'] != '') {
$user->jobtitle = $ldapAttr['jobtitle'];
}
if ($settings->ldap_address != '') {
if ($map['address'] != '') {
$user->address = $ldapAttr['address'];
}
if ($settings->ldap_city != '') {
if ($map['city'] != '') {
$user->city = $ldapAttr['city'];
}
if ($settings->ldap_state != '') {
if ($map['state'] != '') {
$user->state = $ldapAttr['state'];
}
if ($settings->ldap_zip != '') {
if ($map['zip'] != '') {
$user->zip = $ldapAttr['zip'];
}
if ($settings->ldap_country != '') {
if ($map['country'] != '') {
$user->country = $ldapAttr['country'];
}
if ($settings->ldap_dept != '' && $ldapAttr['department'] !== '') {
if ($map['department'] != '' && $ldapAttr['department'] !== '') {
$department = Department::firstOrCreate(['name' => $ldapAttr['department']]);
$user->department_id = $department->id;
}
if ($settings->ldap_location != '' && $ldapAttr['location'] !== '') {
if ($map['location'] != '' && $ldapAttr['location'] !== '') {
$location = Location::firstOrCreate(['name' => $ldapAttr['location']]);
$user->location_id = $location->id;
}
@ -455,7 +503,7 @@ class Ldap extends Model
$user = new User;
self::applyLdapAttributesToUser($user, $item);
$user->locale = $item['locale'];
$user->locale = app()->getLocale();
$user->password = $user->noPassword();
if ($settings->ldap_pw_sync == '1') {
$user->password = bcrypt($password);

View File

@ -657,15 +657,28 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
*/
public function isDeletable()
{
return Gate::allows('delete', $this)
&& (($this->assets_count ?? $this->assets()->count()) === 0)
&& $this->hasNoAssignmentBlockers()
&& ($this->deleted_at == '');
}
/**
* The association-blocker half of isDeletable(): true only when the
* user has no assigned assets / accessories / licenses / consumables
* and isn't managing any users or locations. Split out from
* isDeletable() so scripts running outside a request context,
* Artisan `snipeit:ldap-sync --delete` flow in particular, can share
* the exact same rule without needing an authenticated Gate user to
* satisfy the delete-permission check.
*/
public function hasNoAssignmentBlockers(): bool
{
return (($this->assets_count ?? $this->assets()->count()) === 0)
&& (($this->accessories_count ?? $this->accessories()->count()) === 0)
&& (($this->licenses_count ?? $this->licenses()->count()) === 0)
&& (($this->consumables_count ?? $this->consumables()->count()) === 0)
&& (($this->manages_users_count ?? $this->managesUsers()->count()) === 0)
&& (($this->manages_locations_count ?? $this->managedLocations()->count()) === 0)
&& ($this->deleted_at == '');
&& (($this->manages_locations_count ?? $this->managedLocations()->count()) === 0);
}
/**

View File

@ -5,6 +5,7 @@ namespace Database\Seeders;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
class SettingsSeeder extends Seeder
@ -22,7 +23,33 @@ class SettingsSeeder extends Seeder
$settings->label2_2d_type = 'QRCODE';
$settings->default_currency = 'USD';
$settings->brand = 2;
$settings->ldap_enabled = 0;
// Forumsys hosts a free public read-only LDAP directory
// (ldap://ldap.forumsys.com) that's handy for exercising the
// LDAP wizard against a real server without standing up your
// own. Pre-filling the fields lets devs (and the demo site)
// click through Admin > Settings > LDAP and use the Test
// Bind / Test Find User previews end to end. LDAP is enabled
// so the wizard's return-visitor branch unlocks all steps
// for demo visitors. The login controller separately skips
// the LDAP auth branch when the app is in demo mode
// (config('app.lock_passwords')), so demo logins never
// actually hit Forumsys.
$settings->ldap_enabled = '1';
$settings->ldap_server = 'ldap://ldap.forumsys.com';
$settings->is_ad = false;
$settings->ldap_tls = false;
$settings->ldap_server_cert_ignore = false;
$settings->ldap_client_tls_cert = null;
$settings->ldap_client_tls_key = null;
$settings->ldap_basedn = 'dc=example,dc=com';
$settings->ldap_uname = 'cn=read-only-admin,dc=example,dc=com';
$settings->ldap_pword = Crypt::encrypt('password');
$settings->ldap_filter = '';
$settings->ldap_auth_filter_query = 'uid=';
$settings->ldap_username_field = 'uid';
$settings->ldap_fname_field = 'cn';
$settings->ldap_lname_field = 'sn';
$settings->ldap_email = 'mail';
$settings->full_multiple_companies_support = 0;
$settings->label2_1d_type = 'C128';
$settings->skin = 'blue';

View File

@ -306,24 +306,12 @@ parameters:
count: 1
path: app/Console/Commands/LdapSync.php
-
message: '#^Access to an undefined property App\\Models\\User\:\:\$display_name\.$#'
identifier: property.notFound
count: 1
path: app/Console/Commands/LdapSync.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\Relation\:\:attach\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Console/Commands/LdapSync.php
-
message: '#^Call to function array_key_exists\(\) with ''id'' and \*NEVER\* will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: app/Console/Commands/LdapSync.php
-
message: '#^Call to function is_array\(\) with App\\Models\\Location will always evaluate to false\.$#'
identifier: function.impossibleType
@ -351,7 +339,7 @@ parameters:
-
message: '#^Result of && is always false\.$#'
identifier: booleanAnd.alwaysFalse
count: 2
count: 1
path: app/Console/Commands/LdapSync.php
-
@ -360,12 +348,6 @@ parameters:
count: 2
path: app/Console/Commands/LdapSync.php
-
message: '#^Variable \$location in empty\(\) always exists and is not falsy\.$#'
identifier: empty.variable
count: 1
path: app/Console/Commands/LdapSync.php
-
message: '#^Access to an undefined property App\\Console\\Commands\\LdapTroubleshooter\:\:\$settings\.$#'
identifier: property.notFound

View File

@ -28,8 +28,6 @@
name="company_id"
:selected="old('company_id', $item->company_id)"
/>
@else
<input id="hidden_company_id" type="hidden" name="company_id" value="{{ Auth::user()->company_id }}">
@endif
<x-form.row

View File

@ -212,9 +212,6 @@
<div class="box-body">
<span id="wizard-locked-note" class="sr-only">
{{ trans('admin/settings/general.ldap_wizard.locked_help') }}
</span>
{{-- Step title + help text, always the current step's copy. --}}
@php
@ -230,10 +227,22 @@
<x-form.legend for="{{ $currentStep }}" help_text="{!! trans($stepHelpKey) !!}" />
@endif
@if ($isReadOnly)
<x-alert type="warning" role="status" icon="warning">
This is a demo. Every LDAP config field is read-only, but you can still <strong><a href="?step=3">enter
a sample username</a></strong> on step 3 and use the Test Find User button to see the wizard
search against the pre-seeded readonly directory. (You can search on tesla, einstein, or curie.)
The wizard will not actually save any LDAP settings in this demo.
</x-alert>
@endif
{{-- Wizard progress indicator. Same .bs-wizard class the
quickstart setup layout + importer modal use. Flex + flex:1 on
children rather than bootstrap col-md-*, so the layout
stays uniform regardless of step count. --}}
<div class="bs-wizard" style="border-bottom:0; margin-bottom: 25px; display: flex;" role="group" aria-label="{{ trans('admin/settings/general.ldap_wizard.progress_label') }}">
@foreach ($this->stepTitles as $stepNum => $stepTitle)
@php
@ -344,6 +353,7 @@
wire:model.live="is_ad"
:label="trans('admin/settings/general.ad')"
:checked="$is_ad"
:disabled="$isReadOnly"
/>
<!-- AD Domain (only when is_ad is checked) -->
@ -359,6 +369,7 @@
wire:model="ad_domain"
placeholder="{{ trans('general.example').'example.com' }}"
:required="true"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -376,6 +387,7 @@
wire:model.live.debounce.500ms="ldap_server"
placeholder="{{ trans('general.example').'ldap://ldap.example.com' }}"
:required="true"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -387,6 +399,7 @@
:label="trans('admin/settings/general.ldap_tls')"
:checked="$ldap_tls"
help_text="{!! trans('admin/settings/general.ldap_tls_help') !!}"
:disabled="$isReadOnly"
/>
<!-- Ignore LDAP certificate -->
@ -396,6 +409,7 @@
:label="trans('admin/settings/general.ldap_server_cert_ignore')"
:checked="$ldap_server_cert_ignore"
help_text="{!! trans('admin/settings/general.ldap_server_cert_help') !!}"
:disabled="$isReadOnly"
/>
<!-- Client TLS key -->
@ -410,6 +424,7 @@
rows="4"
:placeholder="sprintf('%s-----BEGIN RSA PRIVATE KEY-----%s1234567890%s-----END RSA PRIVATE KEY-----', trans('general.example'), PHP_EOL, PHP_EOL)"
:required="$ldap_client_tls_cert !== ''"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -427,6 +442,7 @@
rows="4"
:placeholder="sprintf('%s-----BEGIN CERTIFICATE-----%s1234567890%s-----END CERTIFICATE-----', trans('general.example'), PHP_EOL, PHP_EOL)"
:required="$ldap_client_tls_key !== ''"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -452,6 +468,7 @@
placeholder="{{ trans('general.example').'ou=users,dc=example,dc=com' }}"
:required="true"
:ignore-autofill="true"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -471,6 +488,7 @@
placeholder="{{ trans('general.example').($is_ad ? 'admin@example.com' : 'cn=admin,dc=example,dc=com') }}"
:ignore-autofill="true"
:required="true"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -487,6 +505,7 @@
wire:model.live.debounce.500ms="ldap_pword"
:required="true"
:ignore-autofill="true"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -503,6 +522,7 @@
wire:model.live.debounce.500ms="ldap_filter"
placeholder="{{ trans('general.example').'&(cn=*)' }}"
:ignore-autofill="true"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -520,6 +540,7 @@
placeholder="{{ trans('general.example').'uid=' }}"
:required="true"
:ignore-autofill="true"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -553,6 +574,7 @@
:placeholder="$placeholderExample !== '' ? trans('general.example').$placeholderExample : ''"
:required="$required"
:ignore-autofill="true"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -568,6 +590,7 @@
:label="trans('admin/settings/general.ldap_invert_active_flag')"
:checked="$ldap_invert_active_flag"
help_text="{!! trans('admin/settings/general.ldap_invert_active_flag_help') !!}"
:disabled="$isReadOnly"
/>
{{-- Sample-lookup section, boxed in an x-well so it
@ -646,7 +669,7 @@
<tbody>
@foreach ($step3TestAttributes as $snipeField => $preview)
<tr>
<td>{{ $snipeField }}</td>
<td>{{ $preview['label'] ?? $snipeField }}</td>
<td>
@if ($preview['attr'])
<code>{{ $preview['attr'] }}</code>
@ -683,6 +706,7 @@
:label="trans('admin/settings/general.ldap_wizard.sync.ldap_pw_sync_label')"
:checked="$ldap_pw_sync"
help_text="{!! trans('admin/settings/general.ldap_pw_sync_help') !!}"
:disabled="$isReadOnly"
/>
<!-- Default permissions group -->
@ -701,6 +725,7 @@
] + $this->permissionGroups"
:forLivewire="true"
style="width: 100%"
:disabled="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -717,6 +742,7 @@
name="custom_forgot_pass_url"
wire:model.blur="custom_forgot_pass_url"
placeholder="{{ trans('general.example').'https://my.ldapserver-forgotpass.com' }}"
:readonly="$isReadOnly"
/>
</x-slot:input>
</x-form.row>
@ -762,7 +788,6 @@
<strong>{{ trans('admin/settings/general.ldap_wizard.verifying_help') }}</strong>
</p>
<x-demo-lock>{{ trans('general.feature_disabled') }}</x-demo-lock>
</div>
</div>
@ -789,7 +814,7 @@
wire:loading.attr="disabled"
wire:target="saveAndAdvance"
class="btn btn-primary"
@disabled(config('app.lock_passwords') || ! $this->canAdvance)
@disabled(! config('app.lock_passwords') && ! $this->canAdvance)
>
<span wire:loading.remove wire:target="saveAndAdvance">
@if ($currentStep === 4)

View File

@ -852,60 +852,32 @@
html += '<div style="overflow:auto;">'
html += '<div>{{ trans('admin/settings/message.ldap.sync_success') }}<br><br></div>'
html += '<table class="table table-striped snipe-table table-bordered table-condensed">'
html += buildLdapResultsTableHeader()
html += buildLdapResultsTableBody(results.user_sync.users)
html += buildLdapResultsTableHeader(results.user_sync.fields)
html += buildLdapResultsTableBody(results.user_sync.users, results.user_sync.fields)
html += '</table></div>'
html += ''
return html;
}
function buildLdapResultsTableHeader(user)
function buildLdapResultsTableHeader(fields)
{
var keys = [
'{{ trans('admin/settings/general.employee_number') }}',
'{{ trans('mail.username') }}',
'{{ trans('admin/users/table.display_name') }}',
'{{ trans('general.first_name') }}',
'{{ trans('general.last_name') }}',
'{{ trans('general.email') }}',
'{{ trans('general.phone') }}',
'{{ trans('admin/users/table.mobile') }}',
'{{ trans('admin/users/table.manager') }}',
'{{ trans('general.address') }}',
'{{ trans('general.city') }}',
'{{ trans('general.state') }}',
'{{ trans('general.zip') }}',
'{{ trans('general.country') }}',
'{{ trans('general.location') }}',
]
let header = '<thead><tr>'
for (var i in keys) {
header += '<th scope="col" style="white-space: nowrap;">' + keys[i] + '</th>'
for (const key in fields) {
header += '<th scope="col" style="white-space: nowrap;">' + fields[key] + '</th>'
}
header += "</tr></thead>"
return header;
}
function buildLdapResultsTableBody(users)
function buildLdapResultsTableBody(users, fields)
{
let body = '<tbody>'
for (var i in users) {
body += '<tr>';
body += '<td style="white-space: nowrap;">' + (users[i].employee_number ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].username ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].display_name ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].firstname ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].lastname ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].email ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].phone ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].mobile ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;"><span class="nullval">' + (users[i].manager ?? 'NULL') + '</span></td>';
body += '<td style="white-space: nowrap;">' + (users[i].address ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].city ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].state ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].zip ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].country ?? '<span class="nullval">NULL</span>') + '</td>';
body += '<td style="white-space: nowrap;">' + (users[i].location ?? '<span class="nullval">NULL</span>') + '</td>';
const nullCell = '<span class="nullval">NULL</span>'
for (const i in users) {
body += '<tr>'
for (const key in fields) {
body += '<td style="white-space: nowrap;">' + (users[i][key] ?? nullCell) + '</td>'
}
body += '</tr>'
}
body += "</tbody>"

View File

@ -18,11 +18,12 @@
@section('header_right')
@can('create', \App\Models\User::class)
@if ($snipeSettings->ldap_enabled == 1)
<a href="{{ route('ldap/user') }}" class="btn btn-theme pull-right"><i class="fas fa-sitemap"></i> {{trans('general.ldap_sync')}}</a>
@endif
@endcan
{{-- LDAP sync surfaces users from across the entire directory
regardless of company scoping, so the button is superuser-only
to match LDAPImportController's authorization. --}}
@if (auth()->user()?->isSuperUser() && $snipeSettings->ldap_enabled == 1)
<a href="{{ route('ldap/user') }}" class="btn btn-theme pull-right"><i class="fas fa-sitemap"></i> {{trans('general.ldap_sync')}}</a>
@endif
@stop
{{-- Page content --}}

View File

@ -8,110 +8,99 @@
{{-- Page content --}}
@section('content')
<div class="row">
<div class="col-md-8 col-md-offset-2">
@if ($snipeSettings->ldap_enabled == 0)
{{ trans('admin/users/message.ldap_not_configured') }}
@else
<x-container columns="1" class="col-md-8 col-md-offset-2">
@if ($snipeSettings->ldap_enabled == 0)
{{ trans('admin/users/message.ldap_not_configured') }}
@else
<x-form id="ldap-form">
<x-box>
<x-callout type="legend" icon="tip" class="col-md-12">
<div class="box box-default">
<form class="form-horizontal" role="form" method="post" action="" id="ldap-form">
{{csrf_field()}}
<div class="box-body">
<div class="callout callout-legend col-md-12">
<p>
<i class="fa-solid fa-lightbulb text-info"></i>
<strong>
{!! trans('admin/users/general.ldap_sync_intro', ['link' => 'https://snipe-it.readme.io/docs/ldap-sync#/']) !!}
</strong>
</p>
<strong>
{!! trans('admin/users/general.ldap_sync_intro', ['link' => 'https://snipe-it.readme.io/docs/ldap-sync#/']) !!}
</strong>
</x-callout>
<x-input.location-select
:label="trans('general.ldap_sync_location')"
name="location_id[]"
:selected="null"
:multiple="true"
:hide-new-button="true"
:help-text="trans('admin/users/general.ldap_config_text')"
/>
<x-slot:customfooter>
<div class="box-footer">
<div class="text-left col-md-6">
<a class="btn btn-link" href="{{ route('users.index') }}">{{ trans('button.cancel') }}</a>
</div>
<!-- Location -->
@include ('partials.forms.edit.location-select', ['translated_name' => trans('general.ldap_sync_location'), 'help_text' => trans('admin/users/general.ldap_config_text'), 'fieldname' => 'location_id[]', 'multiple' => true])
</div><!-- ./box-body -->
<div class="box-footer">
<div class="text-left col-md-6">
<a class="btn btn-link" href="{{ route('users.index') }}">{{ trans('button.cancel') }}</a>
</div>
<div class="text-right col-md-6">
<button type="submit" class="btn btn-primary" id="sync">
<i id="sync-button-icon" class="fas fa-sync-alt icon-white" aria-hidden="true"></i> <span id="sync-button-text">{{ trans('general.synchronize') }}</span>
</button>
</div>
</div> <!-- ./box-footer -->
</form>
</div><!-- /.box -->
@endif
</div><!-- /.col-md-8 -->
</div><!-- /.row -->
<div class="text-right col-md-6">
<x-input.button class="btn-primary" id="sync">
<i id="sync-button-icon" class="fas fa-sync-alt icon-white" aria-hidden="true"></i>
<span id="sync-button-text">{{ trans('general.synchronize') }}</span>
</x-input.button>
</div>
</div>
</x-slot:customfooter>
</x-box>
</x-form>
@endif
</x-container>
@if (Session::get('summary'))
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="box box-default">
<div class="box-header with-border">
<h2 class="box-title">
{{ trans('general.sync_results') }}
</h2>
</div><!-- /.box-header -->
<div class="box-body"><!-- .box-body -->
<table
data-cookie-id-table="ldapUserSync"
data-id-table="ldapUserSyncTable"
data-side-pagination="client"
data-sort-order="asc"
data-sort-name="username"
data-show-refresh="false"
id="customFieldsTable"
data-advanced-search="false"
class="table table-striped snipe-table"
data-export-options='{
"fileName": "ldap-sync-results-{{ date('Y-m-d') }}"
}'>
<thead>
<tr>
<th scope="col" data-sortable="true" data-visible="false" data-searchable="true">{{ trans('general.id') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.username') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('admin/users/table.display_name') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.employee_number') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.first_name') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.last_name') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.email') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.notes') }}</th>
</tr>
</thead>
<tbody>
@foreach (Session::get('summary') as $entry)
<tr>
<td>{{ (array_key_exists('id', $entry)) ? $entry['id'] : '' }}</td>
<td>{{ $entry['username'] }}</td>
<td>{{ $entry['display_name'] }}</td>
<td>{{ $entry['employee_number'] }}</td>
<td>{{ $entry['firstname'] }}</td>
<td>{{ $entry['lastname'] }}</td>
<td>{{ $entry['email'] }}</td>
<td>
@if ($entry['status']=='success')
<span class="text-success"><i class="fas fa-check"></i> {!! $entry['note'] !!}</span>
@else
<span class="alert-msg" role="alert" aria-live="assertive">{!! $entry['note'] !!}</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div><!-- /.box-body -->
</div><!-- /.box -->
</div><!-- /.col-md-12 -->
</div><!-- /.row -->
<x-container columns="1" class="col-md-8 col-md-offset-2">
<x-box :header="trans('general.sync_results')">
<table
data-cookie-id-table="ldapUserSync"
data-id-table="ldapUserSyncTable"
data-side-pagination="client"
data-sort-order="asc"
data-sort-name="username"
data-show-refresh="false"
id="customFieldsTable"
data-advanced-search="false"
class="table table-striped snipe-table"
data-export-options='{
"fileName": "ldap-sync-results-{{ date('Y-m-d') }}"
}'>
<thead>
<tr>
<th scope="col" data-sortable="true" data-visible="false" data-searchable="true">{{ trans('general.id') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.username') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('admin/users/table.display_name') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.employee_number') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.first_name') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.last_name') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.email') }}</th>
<th scope="col" data-sortable="true" data-visible="true" data-searchable="true">{{ trans('general.notes') }}</th>
</tr>
</thead>
<tbody>
@foreach (Session::get('summary') as $entry)
<tr>
<td>{{ (array_key_exists('id', $entry)) ? $entry['id'] : '' }}</td>
<td>{{ $entry['username'] }}</td>
<td>{{ $entry['display_name'] }}</td>
<td>{{ $entry['employee_num'] }}</td>
<td>{{ $entry['first_name'] }}</td>
<td>{{ $entry['last_name'] }}</td>
<td>{{ $entry['email'] }}</td>
<td>
@if ($entry['status']=='success')
<span class="text-success"><i class="fas fa-check"></i> {!! $entry['note'] !!}</span>
@else
<span class="alert-msg" role="alert" aria-live="assertive">{!! $entry['note'] !!}</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</x-box>
</x-container>
@endif
@stop

View File

@ -95,8 +95,8 @@
@endphp
<div id="start_of_user_section"> {{-- used for page breaks when printing --}}</div>
<h3>
@if ($show_user->company)
<b>{{ trans('admin/companies/table.name') }}:</b> {{ $show_user->company->name }}
@if ($show_user->companies->isNotEmpty())
<b>{{ trans('admin/companies/table.name') }}:</b> {{ $show_user->companies->pluck('name')->join(', ') }}
<br>
@endif
{{ trans('general.assigned_to', ['name' => $show_user->display_name]) }}