First public release. - docs: document the audit log, user offboarding, CSV export and the "require a reason for destructive actions" setting across README, docs/ and CLAUDE/AGENTS.md; note retention for panel_audit - docs: fill the publishing placeholders (repo URL, security contact, issue-template links) and add a semantic-versioning policy - move agent/contributor context into CLAUDE/ (AGENTS.md + a README); root CLAUDE.md is now a thin @-import pointer - add a favicon (shield-lock, matching the login screen) to the login page and the app layout - CHANGELOG: promote [Unreleased] to [1.0.0]; keep the earlier internal build as [0.1.0] Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfuvfwexztdS7LDpGuKsuK
1666 lines
62 KiB
PHP
1666 lines
62 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Ldap\User;
|
|
use App\Services\AttributeCipher;
|
|
use App\Services\AuditLogger;
|
|
use App\Services\LdapAttributeHelper;
|
|
use App\Services\OuTreeService;
|
|
use App\Services\PanelSettingsService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use LdapRecord\Models\ActiveDirectory\Group;
|
|
use LdapRecord\Models\Model;
|
|
use LdapRecord\Models\ModelNotFoundException;
|
|
use LdapRecord\Query\Model\Builder as LdapBuilder;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class UserController extends Controller
|
|
{
|
|
public function __construct(protected AuditLogger $audit) {}
|
|
|
|
/**
|
|
* Lista użytkowników w wybranej OU (wraz z drzewem OU w sidebarze).
|
|
*/
|
|
public function index(Request $request, OuTreeService $ouTree, PanelSettingsService $settings, AttributeCipher $cipher)
|
|
{
|
|
if ($request->boolean('refresh_ou')) {
|
|
OuTreeService::flush();
|
|
}
|
|
|
|
$attributeColumns = $settings->attributeColumns();
|
|
|
|
// Domyślnie zaznaczamy najwyższe OU (root) - jak po jego kliknięciu.
|
|
// ?all_ou=1 wyłącza filtr i pokazuje całą domenę.
|
|
$allOu = $request->boolean('all_ou');
|
|
$rootDn = $allOu ? null : $ouTree->rootDn('users');
|
|
$ouDn = $request->query('ou') ?: $rootDn;
|
|
|
|
$search = trim((string) $request->query('q', ''));
|
|
$includeSubOus = $request->boolean('sub_ous', true);
|
|
$perPage = max(10, min(200, (int) $request->query('per_page', 25)));
|
|
|
|
// Filtr "status konta": '' = wszystkie, 'enabled' = aktywne, 'disabled' = wyłączone.
|
|
$status = $request->query('status');
|
|
$status = in_array($status, ['enabled', 'disabled'], true) ? $status : '';
|
|
|
|
// Filtry z kafelków pulpitu - warunki nieprzekładalne na filtr LDAP
|
|
// (zależą od polityki haseł panelu), więc liczone w PHP po pobraniu.
|
|
$filter = $this->tileFilter($request);
|
|
|
|
$maxPasswordAgeDays = $settings->passwordMaxAgeDays();
|
|
|
|
// 1. Pobieramy dopasowane rekordy z LDAP (paged results control - bez limitu 1000).
|
|
$results = $this->matchingUsersQuery($request, $ouTree)->paginate(500);
|
|
|
|
// 1a. Filtr z kafelka pulpitu (locked / hasło / nieaktywne / wygasłe konto).
|
|
if ($filter !== '') {
|
|
$results = $results
|
|
->filter(fn (User $u) => $this->passesTileFilter($u, $filter, $maxPasswordAgeDays))
|
|
->values();
|
|
}
|
|
|
|
// 2. Ręczne stronnicowanie przy użyciu LengthAwarePaginator Laravela
|
|
$page = LengthAwarePaginator::resolveCurrentPage();
|
|
$total = $results->count();
|
|
|
|
$users = new LengthAwarePaginator(
|
|
$results->forPage($page, $perPage)->values(),
|
|
$total,
|
|
$perPage,
|
|
$page,
|
|
['path' => LengthAwarePaginator::resolveCurrentPath()]
|
|
);
|
|
|
|
// Linki paginacji zachowują filtry, ale nie znacznik partial=1.
|
|
$users->appends(collect($request->query())->except(['partial', 'page'])->all());
|
|
|
|
// 3. Mapowanie obiektów na obiekty prezetacji (działa z wycinkiem z Paginatora Laravela)
|
|
$rows = collect($users->items())->map(function (User $user) use ($maxPasswordAgeDays, $attributeColumns, $cipher) {
|
|
return $this->presentUser($user, $maxPasswordAgeDays, $attributeColumns, $cipher);
|
|
});
|
|
|
|
$viewData = [
|
|
'currentOu' => $ouDn,
|
|
'allOu' => $allOu,
|
|
'search' => $search,
|
|
'status' => $status,
|
|
'filter' => $filter,
|
|
'includeSubOus' => $includeSubOus,
|
|
'users' => $users,
|
|
'rows' => $rows,
|
|
'attributeColumns' => $attributeColumns,
|
|
'attributeCategories' => $settings->categories(),
|
|
'editableAttributesByCategory' => $settings->editableAttributesByCategory(),
|
|
'passwordGenerator' => $settings->passwordGenerator(),
|
|
'requireReason' => $settings->requireReason(),
|
|
];
|
|
|
|
// Dynamiczne wyszukiwanie / paginacja - zwracamy sam region wyników.
|
|
if ($request->boolean('partial')) {
|
|
return view('admin.users.partials.results', $viewData);
|
|
}
|
|
|
|
return view('admin.users.index', $viewData + [
|
|
'ouTree' => $ouTree->tree('users'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Eksport CSV całej dopasowanej listy (respektuje OU / status / szukaj /
|
|
* filtr z kafelka pulpitu) - bez paginacji, strumieniowo.
|
|
*/
|
|
public function export(Request $request, OuTreeService $ouTree, PanelSettingsService $settings, AttributeCipher $cipher): StreamedResponse
|
|
{
|
|
$filter = $this->tileFilter($request);
|
|
$maxPasswordAgeDays = $settings->passwordMaxAgeDays();
|
|
$attributeColumns = $settings->attributeColumns();
|
|
$query = $this->matchingUsersQuery($request, $ouTree);
|
|
|
|
$headings = array_merge(
|
|
['sAMAccountName', __('users.view_modal.title')],
|
|
array_map(fn ($c) => $c['label'], $attributeColumns),
|
|
);
|
|
|
|
return response()->streamDownload(function () use ($query, $filter, $maxPasswordAgeDays, $attributeColumns, $cipher, $headings) {
|
|
$out = fopen('php://output', 'w');
|
|
fprintf($out, "\xEF\xBB\xBF"); // BOM - Excel rozpozna UTF-8
|
|
fputcsv($out, $headings);
|
|
|
|
$query->chunk(1000, function ($users) use ($out, $filter, $maxPasswordAgeDays, $attributeColumns, $cipher) {
|
|
foreach ($users as $user) {
|
|
if ($filter !== '' && ! $this->passesTileFilter($user, $filter, $maxPasswordAgeDays)) {
|
|
continue;
|
|
}
|
|
|
|
$row = $this->presentUser($user, $maxPasswordAgeDays, $attributeColumns, $cipher);
|
|
$line = [$row['samaccountname'], $row['display_name']];
|
|
|
|
foreach ($attributeColumns as $col) {
|
|
$line[] = match ($col['key']) {
|
|
'status' => $row['status_label'],
|
|
'password_expiry' => $row['password_never_expires']
|
|
? __('users.table.password_never_expires')
|
|
: optional($row['password_expires_at'])->format('Y-m-d') ?? '',
|
|
'account_expiry' => optional($row['account_expires_at'] ?? null)->format('Y-m-d')
|
|
?? __('users.table.account_never_expires'),
|
|
default => (string) ($row['attrs'][$col['key']] ?? ''),
|
|
};
|
|
}
|
|
|
|
fputcsv($out, $line);
|
|
}
|
|
});
|
|
|
|
fclose($out);
|
|
}, 'users-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
|
}
|
|
|
|
/**
|
|
* Nazwa filtra z kafelka pulpitu, jeśli poprawna (inaczej pusty string).
|
|
*/
|
|
protected function tileFilter(Request $request): string
|
|
{
|
|
$filter = (string) $request->query('filter', '');
|
|
|
|
return in_array($filter, ['locked', 'pwd_never', 'pwd_expiring', 'pwd_expired', 'inactive', 'account_expired', 'no_manager'], true)
|
|
? $filter
|
|
: '';
|
|
}
|
|
|
|
/**
|
|
* Buduje zapytanie LDAP listy użytkowników na podstawie parametrów żądania
|
|
* (OU / status / pełnotekstowe szukanie). Wspólne dla listy i eksportu.
|
|
*/
|
|
protected function matchingUsersQuery(Request $request, OuTreeService $ouTree): LdapBuilder
|
|
{
|
|
$allOu = $request->boolean('all_ou');
|
|
$ouDn = $request->query('ou') ?: ($allOu ? null : $ouTree->rootDn('users'));
|
|
$includeSubOus = $request->boolean('sub_ous', true);
|
|
|
|
$status = $request->query('status');
|
|
$status = in_array($status, ['enabled', 'disabled'], true) ? $status : '';
|
|
|
|
$search = trim((string) $request->query('q', ''));
|
|
|
|
$query = User::query();
|
|
|
|
if ($ouDn) {
|
|
$query->in($ouDn);
|
|
$includeSubOus ? $query->recursive() : $query->listing();
|
|
}
|
|
|
|
if ($status === 'enabled') {
|
|
$query->whereEnabled();
|
|
} elseif ($status === 'disabled') {
|
|
$query->whereDisabled();
|
|
}
|
|
|
|
if ($search !== '') {
|
|
// Pełnotekstowo po wszystkich atrybutach z konfiguracji + cn.
|
|
$searchAttrs = array_values(array_unique(array_merge(
|
|
array_map('strtolower', array_values(User::attributeMap())),
|
|
['cn'],
|
|
)));
|
|
|
|
$query->orFilter(function ($q) use ($searchAttrs, $search) {
|
|
foreach ($searchAttrs as $attr) {
|
|
$q->whereContains($attr, $search);
|
|
}
|
|
});
|
|
}
|
|
|
|
$selectAttrs = $this->tileFilter($request) === 'no_manager'
|
|
? array_merge(User::panelAttributes(), ['manager', 'directReports'])
|
|
: User::panelAttributes();
|
|
|
|
return $query->select($selectAttrs)->orderBy('cn');
|
|
}
|
|
|
|
/**
|
|
* Czy konto spełnia warunek filtra z kafelka pulpitu (liczony w PHP).
|
|
*/
|
|
protected function passesTileFilter(User $u, string $filter, ?int $maxPasswordAgeDays): bool
|
|
{
|
|
$now = now();
|
|
|
|
switch ($filter) {
|
|
case 'locked':
|
|
return LdapAttributeHelper::isLocked($u);
|
|
case 'pwd_never':
|
|
return LdapAttributeHelper::passwordNeverExpires($u);
|
|
case 'pwd_expiring':
|
|
$e = LdapAttributeHelper::passwordExpiresAt($u, $maxPasswordAgeDays);
|
|
|
|
return $e && ! $e->isPast() && $e->lte($now->copy()->addDays(7));
|
|
case 'pwd_expired':
|
|
$e = LdapAttributeHelper::passwordExpiresAt($u, $maxPasswordAgeDays);
|
|
|
|
return $e && $e->isPast();
|
|
case 'inactive':
|
|
if (LdapAttributeHelper::isDisabled($u)) {
|
|
return false;
|
|
}
|
|
$ll = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lastlogontimestamp'));
|
|
|
|
return ! $ll || $ll->lt($now->copy()->subDays(90));
|
|
case 'account_expired':
|
|
$a = LdapAttributeHelper::accountExpiresAt($u);
|
|
|
|
return $a && $a->isPast();
|
|
case 'no_manager':
|
|
if (LdapAttributeHelper::isDisabled($u)) {
|
|
return false;
|
|
}
|
|
|
|
return trim((string) $u->getFirstAttribute('manager')) === ''
|
|
&& count(array_filter(array_map('trim', $u->getAttribute('directreports') ?? []))) === 0;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zwraca pełne dane pojedynczego użytkownika (do modala "Podgląd / Edycja").
|
|
*/
|
|
public function show(string $samaccountname, AttributeCipher $cipher): JsonResponse
|
|
{
|
|
$user = $this->findBySam($samaccountname);
|
|
|
|
// Wartości atrybutów z mapy, kluczowane kluczami z konfiguracji panelu
|
|
// (np. 'firstName', 'employeeID', 'google') - używane przez formularz profilu.
|
|
$attributes = $user->mappedAttributes();
|
|
|
|
// Atrybuty oznaczone jako szyfrowane pokazujemy odszyfrowane.
|
|
foreach (User::attributeDefinitions() as $def) {
|
|
if (! empty($def['encrypted']) && array_key_exists($def['key'], $attributes)) {
|
|
$attributes[$def['key']] = $cipher->decrypt($attributes[$def['key']]);
|
|
}
|
|
}
|
|
|
|
// Wygasanie konta (accountExpires) - do formularza jako czytelna data
|
|
// (Y-m-d), nie surowy Windows FileTime; puste = konto nigdy nie wygasa.
|
|
// Pole pojawia się w oknie tylko gdy w konfiguracji jest edytowalne.
|
|
$attributes['account_expiry'] = optional(LdapAttributeHelper::accountExpiresAt($user))->format('Y-m-d');
|
|
|
|
// Przełożony (manager) + podwładni (directReports - backlink w AD).
|
|
$resolveDn = function (string $dn): ?Model {
|
|
try {
|
|
return User::query()->select(['cn', 'samaccountname'])->find($dn);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
$manager = null;
|
|
if ($managerDn = $user->getFirstAttribute('manager')) {
|
|
$m = $resolveDn($managerDn);
|
|
$manager = [
|
|
'dn' => $managerDn,
|
|
'name' => $m?->getFirstAttribute('cn') ?? $managerDn,
|
|
'samaccountname' => $m?->getFirstAttribute('samaccountname'),
|
|
];
|
|
}
|
|
|
|
$directReports = collect($user->getAttribute('directreports') ?? [])
|
|
->map(function ($dn) use ($resolveDn) {
|
|
$r = $resolveDn($dn);
|
|
|
|
return [
|
|
'dn' => $dn,
|
|
'name' => $r?->getFirstAttribute('cn') ?? $dn,
|
|
'samaccountname' => $r?->getFirstAttribute('samaccountname'),
|
|
];
|
|
})
|
|
->sortBy('name')
|
|
->values();
|
|
|
|
// Zdjęcie (thumbnailPhoto) jako data URI.
|
|
$photo = null;
|
|
if ($raw = $user->getFirstAttribute('thumbnailphoto')) {
|
|
$photo = 'data:image/jpeg;base64,'.base64_encode($raw);
|
|
}
|
|
|
|
return response()->json([
|
|
// Klucze płaskie zachowane dla zgodności z istniejącym frontendem.
|
|
'samaccountname' => $attributes['login'] ?? $user->getFirstAttribute('samaccountname'),
|
|
'given_name' => $attributes['firstName'] ?? null,
|
|
'surname' => $attributes['lastName'] ?? null,
|
|
'display_name' => $user->getFirstAttribute('cn'),
|
|
'mail' => $attributes['email'] ?? null,
|
|
'telephone' => $attributes['telephoneNumber'] ?? null,
|
|
'title' => $attributes['title'] ?? null,
|
|
'department' => $attributes['department'] ?? null,
|
|
'upn' => $user->getFirstAttribute('userprincipalname'),
|
|
'dn' => $user->getDn(),
|
|
'attributes' => $attributes,
|
|
'password_last_set' => optional(LdapAttributeHelper::passwordLastSet($user))->format('Y-m-d H:i'),
|
|
'account_expires_at' => optional(LdapAttributeHelper::accountExpiresAt($user))->format('Y-m-d'),
|
|
'disabled' => LdapAttributeHelper::isDisabled($user),
|
|
'locked' => LdapAttributeHelper::isLocked($user),
|
|
'password_never_expires' => LdapAttributeHelper::passwordNeverExpires($user),
|
|
'manager' => $manager,
|
|
'direct_reports' => $directReports,
|
|
'photo' => $photo,
|
|
'groups' => collect($user->groups()->get())->map(fn ($g) => [
|
|
'name' => $g->getFirstAttribute('cn'),
|
|
'dn' => $g->getDn(),
|
|
])->values(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Tworzy nowego użytkownika w wybranej OU.
|
|
*/
|
|
public function store(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'given_name' => ['required', 'string', 'max:64'],
|
|
'surname' => ['required', 'string', 'max:64'],
|
|
'samaccountname' => ['required', 'string', 'max:20', 'regex:/^[^\\/\\\\\\[\\]:;|=,+*?<>"@]+$/'],
|
|
'mail' => ['nullable', 'email'],
|
|
'ou' => ['required', 'string'],
|
|
'password' => ['required', 'string', 'min:8'],
|
|
'must_change_password' => ['nullable', 'boolean'],
|
|
'account_enabled' => ['nullable', 'boolean'],
|
|
// Wzorzec ("utwórz jak...") - kopiuje atrybuty organizacyjne i grupy.
|
|
'template_sam' => ['nullable', 'string', 'max:64'],
|
|
'copy_attributes' => ['nullable', 'boolean'],
|
|
'copy_groups' => ['nullable', 'boolean'],
|
|
// Onboarding: wybrane grupy.
|
|
'groups' => ['nullable', 'array'],
|
|
'groups.*' => ['string', 'max:256'],
|
|
]);
|
|
|
|
$domain = config('ldap.defaults.domain_label');
|
|
$upnPrefix = $data['samaccountname'];
|
|
|
|
$template = null;
|
|
if (! empty($data['template_sam'])) {
|
|
$template = User::query()->where('samaccountname', '=', $data['template_sam'])->first();
|
|
}
|
|
|
|
$user = new User;
|
|
$user->inside($data['ou']);
|
|
$user->cn = $data['given_name'].' '.$data['surname'];
|
|
$user->givenname = $data['given_name'];
|
|
$user->sn = $data['surname'];
|
|
$user->samaccountname = $data['samaccountname'];
|
|
$user->userprincipalname = "{$upnPrefix}@{$domain}";
|
|
|
|
if (! empty($data['mail'])) {
|
|
$user->mail = $data['mail'];
|
|
}
|
|
|
|
if ($template && ! empty($data['copy_attributes'])) {
|
|
foreach (['title', 'department', 'company', 'physicaldeliveryofficename', 'co', 'st', 'l', 'streetaddress', 'postalcode', 'manager'] as $attr) {
|
|
if ($v = $template->getFirstAttribute($attr)) {
|
|
$user->setFirstAttribute($attr, $v);
|
|
}
|
|
}
|
|
}
|
|
|
|
$user->unicodepwd = $this->encodePassword($data['password']);
|
|
|
|
$uac = 512; // NORMAL_ACCOUNT
|
|
if (empty($data['account_enabled'])) {
|
|
$uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
}
|
|
$user->userAccountControl = $uac;
|
|
|
|
$user->save();
|
|
|
|
if (! empty($data['must_change_password'])) {
|
|
$user->pwdlastset = 0;
|
|
$user->save();
|
|
}
|
|
|
|
// Grupy: z wzorca (jeśli zaznaczono) + wybrane wprost, bez duplikatów.
|
|
$addedGroups = [];
|
|
$failedGroups = [];
|
|
$seenGroupDn = [];
|
|
|
|
$attachGroup = function (?Group $group, string $label) use ($user, &$addedGroups, &$failedGroups, &$seenGroupDn) {
|
|
if (! $group) {
|
|
$failedGroups[] = $label;
|
|
|
|
return;
|
|
}
|
|
$dn = strtolower($group->getDn());
|
|
if (isset($seenGroupDn[$dn])) {
|
|
return;
|
|
}
|
|
$seenGroupDn[$dn] = true;
|
|
try {
|
|
$group->members()->attach($user);
|
|
$addedGroups[] = $group->getFirstAttribute('cn') ?: $label;
|
|
} catch (\Throwable) {
|
|
$failedGroups[] = $group->getFirstAttribute('cn') ?: $label;
|
|
}
|
|
};
|
|
|
|
if ($template && ! empty($data['copy_groups'])) {
|
|
foreach ($template->groups()->get() as $group) {
|
|
$attachGroup($group, (string) ($group->getFirstAttribute('cn') ?? ''));
|
|
}
|
|
}
|
|
|
|
foreach (array_unique($data['groups'] ?? []) as $cn) {
|
|
$cn = trim((string) $cn);
|
|
if ($cn === '') {
|
|
continue;
|
|
}
|
|
$attachGroup(Group::query()->where('cn', '=', $cn)->first(), $cn);
|
|
}
|
|
|
|
$copiedGroups = count($addedGroups);
|
|
$suffix = $template
|
|
? __('users.flash.created_suffix', [
|
|
'template' => $data['template_sam'],
|
|
'groups' => $copiedGroups
|
|
? __('users.flash.created_suffix_groups', ['count' => $copiedGroups])
|
|
: '',
|
|
])
|
|
: '';
|
|
|
|
$this->audit->log('user.create', [
|
|
'target_type' => 'user', 'target' => $data['samaccountname'], 'target_dn' => $user->getDn(),
|
|
'context' => [
|
|
'ou' => $data['ou'],
|
|
'template' => $data['template_sam'] ?? null,
|
|
'groups' => $addedGroups,
|
|
'groups_failed' => $failedGroups,
|
|
'enabled' => ! empty($data['account_enabled']),
|
|
],
|
|
]);
|
|
|
|
return back()
|
|
->with('status', __('users.flash.created', ['name' => $data['samaccountname'], 'suffix' => $suffix]))
|
|
->with('created_user', [
|
|
'sam' => $data['samaccountname'],
|
|
'dn' => $user->getDn(),
|
|
'enabled' => ! empty($data['account_enabled']),
|
|
'must_change_password' => ! empty($data['must_change_password']),
|
|
'groups' => $addedGroups,
|
|
'groups_failed' => $failedGroups,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Aktualizuje dane ogólne / atrybuty konta.
|
|
*/
|
|
public function update(Request $request, string $samaccountname, AttributeCipher $cipher): RedirectResponse
|
|
{
|
|
$user = $this->findBySam($samaccountname);
|
|
|
|
// Zestaw pól edytowalnych pochodzi z konfiguracji panelu (zakładka "Atrybuty").
|
|
$editable = User::editableAttributes();
|
|
|
|
// Klucze pól oznaczonych jako szyfrowane oraz mapa klucz => typ pola.
|
|
$definitions = collect(User::attributeDefinitions());
|
|
$encryptedFields = $definitions
|
|
->filter(fn ($d) => ! empty($d['encrypted']))
|
|
->pluck('key')
|
|
->all();
|
|
$fieldTypes = $definitions->pluck('type', 'key')->all();
|
|
// Reguły walidacji konfigurowane per atrybut (zakładka "Atrybuty").
|
|
$fieldRegex = $definitions->pluck('regex', 'key')->all();
|
|
$fieldRequired = $definitions->pluck('required', 'key')->all();
|
|
|
|
// Pola imię/nazwisko/displayName/login oraz wygasanie konta mają
|
|
// szczególną obsługę, ale tylko jeśli w konfiguracji są oznaczone jako
|
|
// edytowalne.
|
|
$special = ['firstName', 'lastName', 'displayName', 'login', 'account_expiry'];
|
|
|
|
$rules = [];
|
|
|
|
if (isset($editable['firstName'])) {
|
|
$rules['firstName'] = ['required', 'string', 'max:64'];
|
|
}
|
|
if (isset($editable['lastName'])) {
|
|
$rules['lastName'] = ['required', 'string', 'max:64'];
|
|
}
|
|
if (isset($editable['displayName'])) {
|
|
$rules['displayName'] = ['nullable', 'string', 'max:256'];
|
|
}
|
|
if (isset($editable['login'])) {
|
|
$rules['login'] = ['required', 'string', 'max:20', 'regex:/^[^\\/\\\\\\[\\]:;|=,+*?<>"@]+$/'];
|
|
}
|
|
if (isset($editable['account_expiry'])) {
|
|
$rules['account_expiry'] = ['nullable', 'date'];
|
|
}
|
|
|
|
foreach ($editable as $field => $ldapAttribute) {
|
|
if (isset($rules[$field]) || in_array($field, $special, true)) {
|
|
continue;
|
|
}
|
|
|
|
$fieldRule = match (true) {
|
|
$field === 'email' => ['nullable', 'email', 'max:320'],
|
|
($fieldTypes[$field] ?? 'text') === 'number' => ['nullable', 'numeric'],
|
|
($fieldTypes[$field] ?? 'text') === 'date' => ['nullable', 'date'],
|
|
default => ['nullable', 'string', 'max:2048'],
|
|
};
|
|
|
|
// Pole wymagane w konfiguracji: zamień "nullable" na "required".
|
|
if (! empty($fieldRequired[$field])) {
|
|
$fieldRule = array_values(array_diff($fieldRule, ['nullable']));
|
|
array_unshift($fieldRule, 'required');
|
|
}
|
|
|
|
// Wzorzec (regex z ogranicznikami) - stosowany tylko do niepustych wartości.
|
|
if (! empty($fieldRegex[$field])) {
|
|
$fieldRule[] = 'regex:'.$fieldRegex[$field];
|
|
}
|
|
|
|
$rules[$field] = $fieldRule;
|
|
}
|
|
|
|
$data = $request->validate($rules);
|
|
|
|
// Imię / nazwisko + pochodne (cn, displayName).
|
|
$hasFirst = isset($editable['firstName']);
|
|
$hasLast = isset($editable['lastName']);
|
|
|
|
if ($hasFirst) {
|
|
$user->givenname = $data['firstName'];
|
|
}
|
|
if ($hasLast) {
|
|
$user->sn = $data['lastName'];
|
|
}
|
|
if ($hasFirst && $hasLast) {
|
|
$user->cn = $data['firstName'].' '.$data['lastName'];
|
|
}
|
|
if (isset($editable['displayName'])) {
|
|
$user->displayname = ($data['displayName'] ?? '') !== ''
|
|
? $data['displayName']
|
|
: trim(($data['firstName'] ?? $user->getFirstAttribute('givenname')).' '.($data['lastName'] ?? $user->getFirstAttribute('sn')));
|
|
}
|
|
|
|
// sAMAccountName (login) - zmiana nazwy logowania.
|
|
if (isset($editable['login']) && ($data['login'] ?? '') !== '') {
|
|
$user->setFirstAttribute('samaccountname', $data['login']);
|
|
}
|
|
|
|
// Wygasanie konta (accountExpires) - realny zapis w formacie Windows
|
|
// FileTime. Pusta data = konto nigdy nie wygasa.
|
|
if (isset($editable['account_expiry'])) {
|
|
$user->accountexpires = ($data['account_expiry'] ?? '') !== ''
|
|
? LdapAttributeHelper::dateToWindowsTimestamp(
|
|
Carbon::parse($data['account_expiry'])->endOfDay()
|
|
)
|
|
: LdapAttributeHelper::ACCOUNT_EXPIRES_NEVER;
|
|
}
|
|
|
|
// Pozostałe pola zapisujemy generycznie na bazie mapy atrybutów.
|
|
$changes = [];
|
|
|
|
foreach ($editable as $field => $ldapAttribute) {
|
|
if (in_array($field, $special, true)) {
|
|
continue;
|
|
}
|
|
|
|
$value = ($data[$field] ?? '') !== '' ? $data[$field] : null;
|
|
$isEncrypted = in_array($field, $encryptedFields, true);
|
|
|
|
$old = $user->getFirstAttribute($ldapAttribute);
|
|
if ((string) ($old ?? '') !== (string) ($value ?? '')) {
|
|
$changes[$field] = $isEncrypted
|
|
? ['***', '***']
|
|
: [$old, $value];
|
|
}
|
|
|
|
if ($value !== null && $isEncrypted) {
|
|
$value = $cipher->encrypt($value);
|
|
}
|
|
|
|
$user->setFirstAttribute($ldapAttribute, $value);
|
|
}
|
|
|
|
$user->save();
|
|
|
|
Cache::store('file')->forget('org_chart:v3');
|
|
Cache::store('file')->forget('dashboard:summary');
|
|
|
|
$this->audit->log('user.update', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'changes' => $changes ?: null,
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.updated'));
|
|
}
|
|
|
|
/**
|
|
* Resetuje hasło użytkownika.
|
|
*/
|
|
public function resetPassword(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'password' => ['required', 'string', 'min:8'],
|
|
'must_change_password' => ['nullable', 'boolean'],
|
|
'unlock_account' => ['nullable', 'boolean'],
|
|
'reason' => $this->reasonRule(false),
|
|
]);
|
|
|
|
$user = $this->findBySam($samaccountname);
|
|
$user->unicodepwd = $this->encodePassword($data['password']);
|
|
$user->save();
|
|
|
|
if (! empty($data['must_change_password'])) {
|
|
$user->pwdlastset = 0;
|
|
} else {
|
|
$user->pwdlastset = -1;
|
|
}
|
|
|
|
if (! empty($data['unlock_account'])) {
|
|
$user->lockouttime = 0;
|
|
}
|
|
|
|
$user->save();
|
|
|
|
$this->audit->log('user.reset_password', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'reason' => $data['reason'] ?? null,
|
|
'context' => [
|
|
'must_change_password' => (bool) ($data['must_change_password'] ?? false),
|
|
'unlocked' => (bool) ($data['unlock_account'] ?? false),
|
|
],
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.password_reset', ['name' => $samaccountname]));
|
|
}
|
|
|
|
/**
|
|
* Ustawia politykę wygasania hasła dla konta.
|
|
*/
|
|
public function passwordExpiry(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'never_expires' => ['nullable', 'boolean'],
|
|
'account_never' => ['nullable', 'boolean'],
|
|
'account_expire_on' => ['nullable', 'date'],
|
|
'reason' => $this->reasonRule(false),
|
|
]);
|
|
|
|
$user = $this->findBySam($samaccountname);
|
|
$uac = LdapAttributeHelper::userAccountControl($user);
|
|
|
|
if (! empty($data['never_expires'])) {
|
|
$uac |= LdapAttributeHelper::UAC_DONT_EXPIRE_PASSWORD;
|
|
} else {
|
|
$uac &= ~LdapAttributeHelper::UAC_DONT_EXPIRE_PASSWORD;
|
|
}
|
|
|
|
$user->userAccountControl = $uac;
|
|
|
|
// Wygaśnięcie konta (accountExpires) - realny zapis, nie hack na "info".
|
|
if (! empty($data['account_never'])) {
|
|
$user->accountexpires = LdapAttributeHelper::ACCOUNT_EXPIRES_NEVER;
|
|
} elseif (! empty($data['account_expire_on'])) {
|
|
$user->accountexpires = LdapAttributeHelper::dateToWindowsTimestamp(
|
|
Carbon::parse($data['account_expire_on'])->endOfDay()
|
|
);
|
|
}
|
|
|
|
$user->save();
|
|
|
|
$this->audit->log('user.password_expiry', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'reason' => $data['reason'] ?? null,
|
|
'context' => [
|
|
'password_never_expires' => (bool) ($data['never_expires'] ?? false),
|
|
'account_never' => (bool) ($data['account_never'] ?? false),
|
|
'account_expire_on' => $data['account_expire_on'] ?? null,
|
|
],
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.expiry_updated'));
|
|
}
|
|
|
|
/**
|
|
* Przenosi użytkownika do innej jednostki organizacyjnej.
|
|
*/
|
|
public function move(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'target_ou' => ['required', 'string'],
|
|
'reason' => $this->reasonRule(true),
|
|
]);
|
|
|
|
$user = $this->findBySam($samaccountname);
|
|
$fromDn = $user->getDn();
|
|
$user->move($data['target_ou']);
|
|
|
|
$this->audit->log('user.move', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $fromDn,
|
|
'reason' => $data['reason'] ?? null,
|
|
'changes' => ['ou' => [$fromDn, $data['target_ou']]],
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.moved'));
|
|
}
|
|
|
|
/**
|
|
* Włącza / wyłącza konto i czyści lockoutTime.
|
|
*/
|
|
public function toggleEnabled(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$user = $this->findBySam($samaccountname);
|
|
$uac = LdapAttributeHelper::userAccountControl($user);
|
|
|
|
if (LdapAttributeHelper::isDisabled($user)) {
|
|
$uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
$message = __('users.flash.unlocked');
|
|
$action = 'user.enable';
|
|
} else {
|
|
$uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
$message = __('users.flash.locked');
|
|
$action = 'user.disable';
|
|
}
|
|
|
|
$user->userAccountControl = $uac;
|
|
$user->lockouttime = 0;
|
|
$user->save();
|
|
|
|
$this->audit->log($action, [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'reason' => $request->input('reason') ?: null,
|
|
]);
|
|
|
|
return back()->with('status', $message);
|
|
}
|
|
|
|
public function destroy(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$data = $request->validate(['reason' => $this->reasonRule(true)]);
|
|
|
|
$user = $this->findBySam($samaccountname);
|
|
$dn = $user->getDn();
|
|
$user->delete();
|
|
|
|
$this->audit->log('user.delete', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $dn,
|
|
'reason' => $data['reason'] ?? null,
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.deleted'));
|
|
}
|
|
|
|
/**
|
|
* Wgrywa / usuwa zdjęcie (thumbnailPhoto) - skalowane do 96x96 JPEG.
|
|
*/
|
|
public function photo(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'photo' => ['nullable', 'image', 'mimes:jpeg,jpg,png', 'max:2048'],
|
|
'remove_photo' => ['nullable', 'boolean'],
|
|
]);
|
|
|
|
$user = $this->findBySam($samaccountname);
|
|
|
|
if (! empty($data['remove_photo'])) {
|
|
$user->thumbnailphoto = null;
|
|
$user->save();
|
|
$this->audit->log('user.photo', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'context' => ['removed' => true],
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.photo_removed'));
|
|
}
|
|
|
|
if ($request->hasFile('photo')) {
|
|
$user->thumbnailphoto = $this->makeThumbnail($request->file('photo')->get());
|
|
$user->save();
|
|
$this->audit->log('user.photo', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.photo_saved'));
|
|
}
|
|
|
|
return back();
|
|
}
|
|
|
|
/**
|
|
* Kadruje do kwadratu i skaluje obraz do miniatury JPEG (dla thumbnailPhoto).
|
|
*/
|
|
protected function makeThumbnail(string $raw, int $size = 96): string
|
|
{
|
|
if (! function_exists('imagecreatefromstring')) {
|
|
return $raw; // brak rozszerzenia GD - zapisujemy oryginał
|
|
}
|
|
|
|
$src = @imagecreatefromstring($raw);
|
|
if ($src === false) {
|
|
return $raw;
|
|
}
|
|
|
|
$w = imagesx($src);
|
|
$h = imagesy($src);
|
|
$side = min($w, $h);
|
|
|
|
$dst = imagecreatetruecolor($size, $size);
|
|
imagecopyresampled(
|
|
$dst, $src,
|
|
0, 0,
|
|
(int) (($w - $side) / 2), (int) (($h - $side) / 2),
|
|
$size, $size, $side, $side
|
|
);
|
|
|
|
ob_start();
|
|
imagejpeg($dst, null, 82);
|
|
$out = ob_get_clean();
|
|
|
|
imagedestroy($src);
|
|
imagedestroy($dst);
|
|
|
|
return $out !== false && $out !== '' ? $out : $raw;
|
|
}
|
|
|
|
/**
|
|
* Akcje masowe.
|
|
*/
|
|
public function bulkResetPassword(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'samaccountnames' => ['required', 'array', 'min:1'],
|
|
'samaccountnames.*' => ['string'],
|
|
'password' => ['required', 'string', 'min:8'],
|
|
'must_change_password' => ['nullable', 'boolean'],
|
|
'unlock_account' => ['nullable', 'boolean'],
|
|
'reason' => $this->reasonRule(false),
|
|
]);
|
|
|
|
foreach ($data['samaccountnames'] as $sam) {
|
|
$user = $this->findBySam($sam);
|
|
$user->unicodepwd = $this->encodePassword($data['password']);
|
|
$user->pwdlastset = empty($data['must_change_password']) ? -1 : 0;
|
|
|
|
if (! empty($data['unlock_account'])) {
|
|
$user->lockouttime = 0;
|
|
}
|
|
|
|
$user->save();
|
|
}
|
|
|
|
$this->auditBulk('user.bulk_reset_password', $data['samaccountnames'], $data['reason'] ?? null);
|
|
|
|
return back()->with('status', __('users.flash.bulk_password_reset', ['count' => count($data['samaccountnames'])]));
|
|
}
|
|
|
|
public function bulkMove(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'samaccountnames' => ['required', 'array', 'min:1'],
|
|
'samaccountnames.*' => ['string'],
|
|
'target_ou' => ['required', 'string'],
|
|
'reason' => $this->reasonRule(true),
|
|
]);
|
|
|
|
foreach ($data['samaccountnames'] as $sam) {
|
|
$this->findBySam($sam)->move($data['target_ou']);
|
|
}
|
|
|
|
$this->auditBulk('user.bulk_move', $data['samaccountnames'], $data['reason'] ?? null, [
|
|
'target_ou' => $data['target_ou'],
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.bulk_moved', ['count' => count($data['samaccountnames'])]));
|
|
}
|
|
|
|
public function bulkPasswordExpiry(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'samaccountnames' => ['required', 'array', 'min:1'],
|
|
'samaccountnames.*' => ['string'],
|
|
'never_expires' => ['nullable', 'boolean'],
|
|
'reason' => $this->reasonRule(false),
|
|
]);
|
|
|
|
foreach ($data['samaccountnames'] as $sam) {
|
|
$user = $this->findBySam($sam);
|
|
$uac = LdapAttributeHelper::userAccountControl($user);
|
|
|
|
if (! empty($data['never_expires'])) {
|
|
$uac |= LdapAttributeHelper::UAC_DONT_EXPIRE_PASSWORD;
|
|
} else {
|
|
$uac &= ~LdapAttributeHelper::UAC_DONT_EXPIRE_PASSWORD;
|
|
}
|
|
|
|
$user->userAccountControl = $uac;
|
|
$user->save();
|
|
}
|
|
|
|
$this->auditBulk('user.bulk_password_expiry', $data['samaccountnames'], $data['reason'] ?? null, [
|
|
'password_never_expires' => (bool) ($data['never_expires'] ?? false),
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.bulk_expiry_updated', ['count' => count($data['samaccountnames'])]));
|
|
}
|
|
|
|
/**
|
|
* Włącza lub wyłącza wskazane konto (bez przełączania - stan wprost).
|
|
* Przy włączaniu czyści też blokadę logowania (lockoutTime).
|
|
*/
|
|
public function setEnabled(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'enabled' => ['nullable', 'boolean'],
|
|
'reason' => $this->reasonRule(true),
|
|
]);
|
|
|
|
$enabled = (bool) ($data['enabled'] ?? false);
|
|
$user = $this->findBySam($samaccountname);
|
|
$this->applyEnabled($user, $enabled);
|
|
|
|
$this->audit->log($enabled ? 'user.enable' : 'user.disable', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'reason' => $data['reason'] ?? null,
|
|
]);
|
|
|
|
return back()->with('status', __($enabled ? 'users.flash.unlocked' : 'users.flash.locked'));
|
|
}
|
|
|
|
public function bulkSetEnabled(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'samaccountnames' => ['required', 'array', 'min:1'],
|
|
'samaccountnames.*' => ['string'],
|
|
'enabled' => ['nullable', 'boolean'],
|
|
'reason' => $this->reasonRule(true),
|
|
]);
|
|
|
|
$enabled = (bool) ($data['enabled'] ?? false);
|
|
|
|
foreach ($data['samaccountnames'] as $sam) {
|
|
$this->applyEnabled($this->findBySam($sam), $enabled);
|
|
}
|
|
|
|
Cache::store('file')->forget('dashboard:summary');
|
|
|
|
$this->auditBulk($enabled ? 'user.bulk_enable' : 'user.bulk_disable', $data['samaccountnames'], $data['reason'] ?? null);
|
|
|
|
return back()->with('status', __(
|
|
$enabled ? 'users.flash.bulk_enabled' : 'users.flash.bulk_disabled',
|
|
['count' => count($data['samaccountnames'])],
|
|
));
|
|
}
|
|
|
|
protected function applyEnabled(User $user, bool $enabled): void
|
|
{
|
|
$uac = LdapAttributeHelper::userAccountControl($user);
|
|
|
|
if ($enabled) {
|
|
$uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
$user->lockouttime = 0;
|
|
} else {
|
|
$uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
}
|
|
|
|
$user->userAccountControl = $uac;
|
|
$user->save();
|
|
}
|
|
|
|
/**
|
|
* Dodaje albo usuwa wskazanych użytkowników z jednej grupy.
|
|
*/
|
|
public function bulkGroup(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'samaccountnames' => ['required', 'array', 'min:1'],
|
|
'samaccountnames.*' => ['string'],
|
|
'group_cn' => ['required', 'string'],
|
|
'mode' => ['required', 'in:add,remove'],
|
|
'reason' => $this->reasonRule($request->input('mode') === 'remove'),
|
|
]);
|
|
|
|
$group = Group::query()->where('cn', '=', $data['group_cn'])->firstOrFail();
|
|
$add = $data['mode'] === 'add';
|
|
$done = 0;
|
|
|
|
foreach ($data['samaccountnames'] as $sam) {
|
|
try {
|
|
$user = $this->findBySam($sam);
|
|
$add ? $group->members()->attach($user) : $group->members()->detach($user);
|
|
$done++;
|
|
} catch (\Throwable) {
|
|
// np. brak uprawnień / grupa podstawowa - pomijamy.
|
|
}
|
|
}
|
|
|
|
$this->auditBulk('user.bulk_group', $data['samaccountnames'], $data['reason'] ?? null, [
|
|
'group' => $data['group_cn'],
|
|
'mode' => $data['mode'],
|
|
'applied' => $done,
|
|
]);
|
|
|
|
return back()->with('status', __(
|
|
$add ? 'users.flash.bulk_group_added' : 'users.flash.bulk_group_removed',
|
|
['count' => $done, 'group' => $data['group_cn']],
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Wymusza zmianę hasła przy następnym logowaniu (pwdLastSet = 0),
|
|
* bez zmiany samego hasła.
|
|
*/
|
|
public function forcePasswordChange(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$user = $this->findBySam($samaccountname);
|
|
$user->pwdlastset = 0;
|
|
$user->save();
|
|
|
|
$this->audit->log('user.force_password_change', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'reason' => $request->input('reason') ?: null,
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.force_pwd_change', ['name' => $samaccountname]));
|
|
}
|
|
|
|
public function bulkForcePasswordChange(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'samaccountnames' => ['required', 'array', 'min:1'],
|
|
'samaccountnames.*' => ['string'],
|
|
'reason' => $this->reasonRule(false),
|
|
]);
|
|
|
|
foreach ($data['samaccountnames'] as $sam) {
|
|
$user = $this->findBySam($sam);
|
|
$user->pwdlastset = 0;
|
|
$user->save();
|
|
}
|
|
|
|
$this->auditBulk('user.bulk_force_password_change', $data['samaccountnames'], $data['reason'] ?? null);
|
|
|
|
return back()->with('status', __('users.flash.bulk_force_pwd_change', ['count' => count($data['samaccountnames'])]));
|
|
}
|
|
|
|
/**
|
|
* Odblokowuje konto (czyści lockoutTime) - bez zmiany stanu włączenia.
|
|
*/
|
|
public function unlock(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$user = $this->findBySam($samaccountname);
|
|
$user->lockouttime = 0;
|
|
$user->save();
|
|
|
|
Cache::store('file')->forget('dashboard:summary');
|
|
|
|
$this->audit->log('user.unlock', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'reason' => $request->input('reason') ?: null,
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.unlocked_one', ['name' => $samaccountname]));
|
|
}
|
|
|
|
public function bulkUnlock(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'samaccountnames' => ['required', 'array', 'min:1'],
|
|
'samaccountnames.*' => ['string'],
|
|
'reason' => $this->reasonRule(false),
|
|
]);
|
|
|
|
foreach ($data['samaccountnames'] as $sam) {
|
|
$user = $this->findBySam($sam);
|
|
$user->lockouttime = 0;
|
|
$user->save();
|
|
}
|
|
|
|
Cache::store('file')->forget('dashboard:summary');
|
|
|
|
$this->auditBulk('user.bulk_unlock', $data['samaccountnames'], $data['reason'] ?? null);
|
|
|
|
return back()->with('status', __('users.flash.bulk_unlocked', ['count' => count($data['samaccountnames'])]));
|
|
}
|
|
|
|
/**
|
|
* Klucze pól tożsamości - nie edytujemy ich masowo.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
protected const BULK_ATTR_EXCLUDED = ['login', 'firstName', 'lastName', 'displayName'];
|
|
|
|
/**
|
|
* Masowa edycja atrybutów: dla zaznaczonych kont ustawia tylko te pola,
|
|
* które administrator zaznaczył checkboxem ("apply"). Puste + zaznaczone
|
|
* czyści atrybut (chyba że w konfiguracji jest wymagany).
|
|
*/
|
|
public function bulkAttributes(Request $request, AttributeCipher $cipher): RedirectResponse
|
|
{
|
|
$editable = User::editableAttributes();
|
|
$definitions = collect(User::attributeDefinitions());
|
|
$fieldTypes = $definitions->pluck('type', 'key')->all();
|
|
$fieldRegex = $definitions->pluck('regex', 'key')->all();
|
|
$fieldRequired = $definitions->pluck('required', 'key')->all();
|
|
$encryptedFields = $definitions->filter(fn ($d) => ! empty($d['encrypted']))->pluck('key')->all();
|
|
|
|
$allowed = array_values(array_diff(array_keys($editable), self::BULK_ATTR_EXCLUDED));
|
|
$applied = array_values(array_intersect((array) $request->input('apply', []), $allowed));
|
|
|
|
$rules = [
|
|
'samaccountnames' => ['required', 'array', 'min:1'],
|
|
'samaccountnames.*' => ['string'],
|
|
'apply' => ['required', 'array', 'min:1'],
|
|
'reason' => $this->reasonRule(false),
|
|
];
|
|
|
|
foreach ($applied as $key) {
|
|
if ($key === 'manager') {
|
|
$rules['attributes.manager'] = ['nullable', 'string'];
|
|
|
|
continue;
|
|
}
|
|
if ($key === 'account_expiry') {
|
|
$rules['attributes.account_expiry'] = ['nullable', 'date'];
|
|
|
|
continue;
|
|
}
|
|
|
|
$type = $fieldTypes[$key] ?? 'text';
|
|
$rule = match (true) {
|
|
$key === 'email' => ['nullable', 'email', 'max:320'],
|
|
$type === 'number' => ['nullable', 'numeric'],
|
|
$type === 'date' => ['nullable', 'date'],
|
|
default => ['nullable', 'string', 'max:2048'],
|
|
};
|
|
if (! empty($fieldRequired[$key])) {
|
|
$rule = array_values(array_diff($rule, ['nullable']));
|
|
array_unshift($rule, 'required');
|
|
}
|
|
if (! empty($fieldRegex[$key])) {
|
|
$rule[] = 'regex:'.$fieldRegex[$key];
|
|
}
|
|
$rules['attributes.'.$key] = $rule;
|
|
}
|
|
|
|
$data = $request->validate($rules);
|
|
$values = $data['attributes'] ?? [];
|
|
|
|
if ($applied === []) {
|
|
return back()->with('status', __('users.flash.bulk_attributes', ['count' => 0, 'fields' => 0]));
|
|
}
|
|
|
|
foreach ($data['samaccountnames'] as $sam) {
|
|
$user = $this->findBySam($sam);
|
|
|
|
foreach ($applied as $key) {
|
|
if ($key === 'manager') {
|
|
$user->setFirstAttribute('manager', ($values['manager'] ?? '') !== '' ? $values['manager'] : null);
|
|
|
|
continue;
|
|
}
|
|
if ($key === 'account_expiry') {
|
|
$user->accountexpires = ($values['account_expiry'] ?? '') !== ''
|
|
? LdapAttributeHelper::dateToWindowsTimestamp(Carbon::parse($values['account_expiry'])->endOfDay())
|
|
: LdapAttributeHelper::ACCOUNT_EXPIRES_NEVER;
|
|
|
|
continue;
|
|
}
|
|
|
|
$value = ($values[$key] ?? '') !== '' ? $values[$key] : null;
|
|
if ($value !== null && in_array($key, $encryptedFields, true)) {
|
|
$value = $cipher->encrypt($value);
|
|
}
|
|
$user->setFirstAttribute($editable[$key], $value);
|
|
}
|
|
|
|
$user->save();
|
|
}
|
|
|
|
Cache::store('file')->forget('org_chart:v3');
|
|
Cache::store('file')->forget('dashboard:summary');
|
|
|
|
$this->auditBulk('user.bulk_attributes', $data['samaccountnames'], $data['reason'] ?? null, [
|
|
'fields' => $applied,
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.bulk_attributes', [
|
|
'count' => count($data['samaccountnames']),
|
|
'fields' => count($applied),
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* Offboarding: wykonuje tylko zaznaczone kroki na jednym lub wielu kontach.
|
|
*/
|
|
public function offboardOne(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$data = $this->validateOffboard($request, single: true);
|
|
$result = $this->runOffboard($this->findBySam($samaccountname), $data['steps'], $data['target_ou'] ?? null, $data['expire_on'] ?? null);
|
|
|
|
$this->finishOffboard([$samaccountname], [$samaccountname => $result], $data);
|
|
|
|
return back()->with('status', __('users.flash.offboarded', ['name' => $samaccountname]));
|
|
}
|
|
|
|
public function bulkOffboard(Request $request): RedirectResponse
|
|
{
|
|
$data = $this->validateOffboard($request, single: false);
|
|
|
|
$results = [];
|
|
foreach ($data['samaccountnames'] as $sam) {
|
|
$results[$sam] = $this->runOffboard($this->findBySam($sam), $data['steps'], $data['target_ou'] ?? null, $data['expire_on'] ?? null);
|
|
}
|
|
|
|
$this->finishOffboard($data['samaccountnames'], $results, $data);
|
|
|
|
return back()->with('status', __('users.flash.bulk_offboarded', ['count' => count($data['samaccountnames'])]));
|
|
}
|
|
|
|
/**
|
|
* @return array{samaccountnames: list<string>, steps: array<string, bool>, target_ou: ?string, expire_on: ?string, reason: ?string}
|
|
*/
|
|
protected function validateOffboard(Request $request, bool $single): array
|
|
{
|
|
$rules = [
|
|
'step_disable' => ['nullable', 'boolean'],
|
|
'step_groups' => ['nullable', 'boolean'],
|
|
'step_groups_keep_primary' => ['nullable', 'boolean'],
|
|
'step_move' => ['nullable', 'boolean'],
|
|
'step_expire' => ['nullable', 'boolean'],
|
|
'target_ou' => ['nullable', 'required_if:step_move,1', 'string'],
|
|
'expire_on' => ['nullable', 'date'],
|
|
'reason' => $this->reasonRule(true),
|
|
];
|
|
if (! $single) {
|
|
$rules['samaccountnames'] = ['required', 'array', 'min:1'];
|
|
$rules['samaccountnames.*'] = ['string'];
|
|
}
|
|
|
|
$v = $request->validate($rules);
|
|
|
|
return [
|
|
'samaccountnames' => $v['samaccountnames'] ?? [],
|
|
'steps' => [
|
|
'disable' => (bool) ($v['step_disable'] ?? false),
|
|
'groups' => (bool) ($v['step_groups'] ?? false),
|
|
'groups_keep_primary' => (bool) ($v['step_groups_keep_primary'] ?? false),
|
|
'move' => (bool) ($v['step_move'] ?? false),
|
|
'expire' => (bool) ($v['step_expire'] ?? false),
|
|
],
|
|
'target_ou' => $v['target_ou'] ?? null,
|
|
'expire_on' => $v['expire_on'] ?? null,
|
|
'reason' => $v['reason'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Wykonuje kroki offboardingu w bezpiecznej kolejności (przeniesienie OU na
|
|
* końcu - zmienia DN). Każdy krok w try/catch; zwraca wynik per krok.
|
|
*
|
|
* @param array<string, bool> $steps
|
|
* @return array<string, string>
|
|
*/
|
|
protected function runOffboard(User $user, array $steps, ?string $targetOu, ?string $expireOn): array
|
|
{
|
|
$result = [];
|
|
$adminName = auth('ldap')->user()?->getFirstAttribute('samaccountname') ?? (session('ad_admin')['login'] ?? 'panel');
|
|
$touched = false;
|
|
|
|
if ($steps['disable'] ?? false) {
|
|
try {
|
|
$user->unicodepwd = $this->encodePassword(bin2hex(random_bytes(24)));
|
|
$user->pwdlastset = -1;
|
|
$uac = LdapAttributeHelper::userAccountControl($user) | LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
$user->userAccountControl = $uac;
|
|
$touched = true;
|
|
$result['disable'] = 'ok';
|
|
} catch (\Throwable $e) {
|
|
$result['disable'] = 'error';
|
|
}
|
|
}
|
|
|
|
if ($steps['expire'] ?? false) {
|
|
try {
|
|
$date = $expireOn ? Carbon::parse($expireOn) : now();
|
|
$user->accountexpires = LdapAttributeHelper::dateToWindowsTimestamp($date->copy()->endOfDay());
|
|
$note = 'Offboarded '.$date->format('Y-m-d').' by '.$adminName;
|
|
$desc = trim((string) $user->getFirstAttribute('description'));
|
|
$user->description = $desc !== '' ? $desc.' | '.$note : $note;
|
|
$touched = true;
|
|
$result['expire'] = 'ok';
|
|
} catch (\Throwable $e) {
|
|
$result['expire'] = 'error';
|
|
}
|
|
}
|
|
|
|
if ($touched) {
|
|
try {
|
|
$user->save();
|
|
} catch (\Throwable $e) {
|
|
$result['save'] = 'error';
|
|
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
if ($steps['groups'] ?? false) {
|
|
$removed = 0;
|
|
$failed = 0;
|
|
foreach ($user->groups()->get() as $group) {
|
|
try {
|
|
$group->members()->detach($user);
|
|
$removed++;
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
}
|
|
}
|
|
$result['groups'] = "removed:{$removed}".($failed ? " failed:{$failed}" : '');
|
|
}
|
|
|
|
if (($steps['move'] ?? false) && $targetOu) {
|
|
try {
|
|
$user->move($targetOu);
|
|
$result['move'] = 'ok';
|
|
} catch (\Throwable $e) {
|
|
$result['move'] = 'error';
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Wpis(y) w dzienniku + czyszczenie cache po offboardingu.
|
|
*
|
|
* @param list<string> $sams
|
|
* @param array<string, array<string, string>> $results
|
|
* @param array{steps: array<string, bool>, target_ou: ?string, expire_on: ?string, reason: ?string} $data
|
|
*/
|
|
protected function finishOffboard(array $sams, array $results, array $data): void
|
|
{
|
|
Cache::store('file')->forget('org_chart:v3');
|
|
Cache::store('file')->forget('dashboard:summary');
|
|
|
|
foreach ($sams as $sam) {
|
|
$this->audit->log('user.offboard', [
|
|
'target_type' => 'user',
|
|
'target' => $sam,
|
|
'reason' => $data['reason'],
|
|
'context' => [
|
|
'steps' => array_keys(array_filter($data['steps'])),
|
|
'target_ou' => $data['target_ou'],
|
|
'expire_on' => $data['expire_on'],
|
|
'results' => $results[$sam] ?? [],
|
|
],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Podpowiedzi użytkowników (typeahead) - do pól "przełożony", "kopiuj z"
|
|
* oraz dodawania członków grupy.
|
|
*/
|
|
public function search(Request $request): JsonResponse
|
|
{
|
|
$q = trim((string) $request->query('q', ''));
|
|
|
|
if (mb_strlen($q) < 2) {
|
|
return response()->json([]);
|
|
}
|
|
|
|
$results = User::query()
|
|
->in(config('ldap.defaults.user_ou'))->recursive()
|
|
->select(['cn', 'samaccountname', 'mail', 'title', 'department'])
|
|
->orFilter(function ($sub) use ($q) {
|
|
foreach (['cn', 'samaccountname', 'mail', 'givenname', 'sn'] as $attr) {
|
|
$sub->whereContains($attr, $q);
|
|
}
|
|
})
|
|
->orderBy('cn')
|
|
->limit(25)
|
|
->paginate(25);
|
|
|
|
return response()->json(
|
|
collect($results)->take(25)->map(fn (User $u) => [
|
|
'dn' => $u->getDn(),
|
|
'cn' => $u->getFirstAttribute('cn'),
|
|
'sam' => $u->getFirstAttribute('samaccountname'),
|
|
'title' => $u->getFirstAttribute('title'),
|
|
'department' => $u->getFirstAttribute('department'),
|
|
])->values()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Struktura organizacyjna wokół użytkownika: łańcuch przełożonych w górę
|
|
* oraz drzewo podwładnych w dół (2 poziomy) - relacja manager / directReports.
|
|
*/
|
|
public function structure(string $samaccountname): JsonResponse
|
|
{
|
|
$user = $this->findBySam($samaccountname);
|
|
|
|
// Łańcuch w górę (ochrona przed pętlą).
|
|
$chain = [];
|
|
$seen = [];
|
|
$current = $user;
|
|
for ($i = 0; $i < 12; $i++) {
|
|
$mgrDn = $current->getFirstAttribute('manager');
|
|
if (! $mgrDn || isset($seen[strtolower($mgrDn)])) {
|
|
break;
|
|
}
|
|
$seen[strtolower($mgrDn)] = true;
|
|
$m = $this->safeFindDn($mgrDn, ['cn', 'samaccountname', 'title', 'manager']);
|
|
if (! $m) {
|
|
break;
|
|
}
|
|
array_unshift($chain, $this->orgNode($m));
|
|
$current = $m;
|
|
}
|
|
|
|
return response()->json([
|
|
'self' => $this->orgNode($user),
|
|
'chain' => $chain,
|
|
'reports' => $this->reportsTree($user, 2),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Dane wzorca do "utwórz jak..." - atrybuty organizacyjne + lista grup.
|
|
*/
|
|
public function template(string $samaccountname): JsonResponse
|
|
{
|
|
$user = $this->findBySam($samaccountname);
|
|
|
|
return response()->json([
|
|
'sam' => $user->getFirstAttribute('samaccountname'),
|
|
'cn' => $user->getFirstAttribute('cn'),
|
|
'ou' => implode(',', array_slice(explode(',', $user->getDn()), 1)),
|
|
'attributes' => [
|
|
'title' => $user->getFirstAttribute('title'),
|
|
'department' => $user->getFirstAttribute('department'),
|
|
'company' => $user->getFirstAttribute('company'),
|
|
'physicalDeliveryOfficeName' => $user->getFirstAttribute('physicaldeliveryofficename'),
|
|
'co' => $user->getFirstAttribute('co'),
|
|
'st' => $user->getFirstAttribute('st'),
|
|
'l' => $user->getFirstAttribute('l'),
|
|
'streetAddress' => $user->getFirstAttribute('streetaddress'),
|
|
'postalCode' => $user->getFirstAttribute('postalcode'),
|
|
'manager' => $user->getFirstAttribute('manager'),
|
|
],
|
|
'groups' => collect($user->groups()->get())->map(fn ($g) => [
|
|
'cn' => $g->getFirstAttribute('cn'),
|
|
'dn' => $g->getDn(),
|
|
])->values(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Dodaje użytkownika do grupy (z poziomu profilu użytkownika).
|
|
*/
|
|
public function addGroup(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$data = $request->validate(['group_cn' => ['required', 'string']]);
|
|
|
|
$user = $this->findBySam($samaccountname);
|
|
$group = Group::query()->where('cn', '=', $data['group_cn'])->firstOrFail();
|
|
$group->members()->attach($user);
|
|
|
|
$this->audit->log('user.group_add', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'context' => ['group' => $data['group_cn']],
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.group_added', ['group' => $data['group_cn']]));
|
|
}
|
|
|
|
/**
|
|
* Usuwa użytkownika z grupy (z poziomu profilu użytkownika).
|
|
*/
|
|
public function removeGroup(Request $request, string $samaccountname): RedirectResponse
|
|
{
|
|
$data = $request->validate(['group_dn' => ['required', 'string']]);
|
|
|
|
$user = $this->findBySam($samaccountname);
|
|
$group = $this->safeFindDn($data['group_dn']);
|
|
if ($group) {
|
|
$group->members()->detach($user);
|
|
}
|
|
|
|
$this->audit->log('user.group_remove', [
|
|
'target_type' => 'user', 'target' => $samaccountname, 'target_dn' => $user->getDn(),
|
|
'context' => ['group_dn' => $data['group_dn']],
|
|
]);
|
|
|
|
return back()->with('status', __('users.flash.group_removed'));
|
|
}
|
|
|
|
/**
|
|
* Reguła walidacji pola "powód": wymagane tylko dla akcji niszczących,
|
|
* gdy w konfiguracji włączono "Wymagaj powodu".
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
protected function reasonRule(bool $destructive): array
|
|
{
|
|
return $destructive && app(PanelSettingsService::class)->requireReason()
|
|
? ['required', 'string', 'max:1000']
|
|
: ['nullable', 'string', 'max:1000'];
|
|
}
|
|
|
|
/**
|
|
* Jeden wpis dziennika dla akcji masowej (lista kont w kontekście).
|
|
*
|
|
* @param list<string> $sams
|
|
* @param array<string, mixed> $extra
|
|
*/
|
|
protected function auditBulk(string $action, array $sams, ?string $reason = null, array $extra = []): void
|
|
{
|
|
$this->audit->log($action, [
|
|
'target_type' => 'user',
|
|
'reason' => $reason,
|
|
'context' => ['count' => count($sams), 'accounts' => array_slice(array_values($sams), 0, 200)] + $extra,
|
|
]);
|
|
}
|
|
|
|
protected function safeFindDn(string $dn, array $selects = ['*']): ?Model
|
|
{
|
|
try {
|
|
return User::query()->select($selects)->find($dn);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function orgNode(Model $u): array
|
|
{
|
|
return [
|
|
'dn' => $u->getDn(),
|
|
'cn' => $u->getFirstAttribute('cn'),
|
|
'sam' => $u->getFirstAttribute('samaccountname'),
|
|
'title' => $u->getFirstAttribute('title'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Rekursywne drzewo podwładnych do zadanej głębokości.
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
protected function reportsTree(Model $user, int $depth): array
|
|
{
|
|
if ($depth <= 0) {
|
|
return [];
|
|
}
|
|
|
|
return collect($user->getAttribute('directreports') ?? [])
|
|
->map(function ($dn) use ($depth) {
|
|
$r = $this->safeFindDn($dn, ['cn', 'samaccountname', 'title', 'directreports']);
|
|
if (! $r) {
|
|
return null;
|
|
}
|
|
|
|
return $this->orgNode($r) + ['reports' => $this->reportsTree($r, $depth - 1)];
|
|
})
|
|
->filter()
|
|
->sortBy('cn')
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
protected function findBySam(string $samaccountname): User
|
|
{
|
|
$user = User::query()->where('samaccountname', '=', $samaccountname)->first();
|
|
|
|
if (! $user) {
|
|
throw new ModelNotFoundException("Nie znaleziono użytkownika: {$samaccountname}");
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
protected function presentUser(User $user, ?int $maxPasswordAgeDays, array $attributeColumns = [], ?AttributeCipher $cipher = null): array
|
|
{
|
|
$cipher ??= app(AttributeCipher::class);
|
|
$status = LdapAttributeHelper::statusBadge($user);
|
|
$expiresAt = LdapAttributeHelper::passwordExpiresAt($user, $maxPasswordAgeDays);
|
|
|
|
$attrs = [];
|
|
foreach ($attributeColumns as $attr) {
|
|
if (! empty($attr['computed']) || $attr['key'] === 'account_expiry') {
|
|
continue; // status / wygasanie hasła / konta - liczone niżej
|
|
}
|
|
$value = $user->getFirstAttribute(strtolower($attr['ldap']));
|
|
$attrs[$attr['key']] = ! empty($attr['encrypted']) ? $cipher->decrypt($value) : $value;
|
|
}
|
|
|
|
return [
|
|
'attrs' => $attrs,
|
|
'samaccountname' => $user->getFirstAttribute('samaccountname'),
|
|
'display_name' => $user->getFirstAttribute('cn'),
|
|
'given_name' => $user->getFirstAttribute('givenname'),
|
|
'surname' => $user->getFirstAttribute('sn'),
|
|
'mail' => $user->getFirstAttribute('mail'),
|
|
'title' => $user->getFirstAttribute('title'),
|
|
'department' => $user->getFirstAttribute('department'),
|
|
'status_label' => $status['label'],
|
|
'status_css' => $status['css'],
|
|
'password_never_expires' => LdapAttributeHelper::passwordNeverExpires($user),
|
|
'password_expires_at' => $expiresAt,
|
|
'password_expires_soon' => $expiresAt && $expiresAt->lessThan(now()->addDays(7)),
|
|
'account_expires_at' => LdapAttributeHelper::accountExpiresAt($user),
|
|
'disabled' => LdapAttributeHelper::isDisabled($user),
|
|
'locked' => LdapAttributeHelper::isLocked($user),
|
|
];
|
|
}
|
|
|
|
protected function encodePassword(string $password): string
|
|
{
|
|
return iconv('UTF-8', 'UTF-16LE', '"'.$password.'"');
|
|
}
|
|
}
|