Files
ADconsle/app/Http/Controllers/Admin/UserController.php
T
kzbkowskiandClaude Sonnet 5 1126d96271
CI / PHP 8.2 (push) Failing after 2m46s
CI / PHP 8.3 (push) Failing after 59s
CI / PHP 8.4 (push) Failing after 1m7s
ADconsole v1.0.0
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
2026-09-08 23:14:39 +02:00

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.'"');
}
}