ADconsole v1.0.0
CI / PHP 8.2 (push) Failing after 2m46s
CI / PHP 8.3 (push) Failing after 59s
CI / PHP 8.4 (push) Failing after 1m7s

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
This commit is contained in:
kzbkowskiandClaude Sonnet 5 committed 2026-09-08 23:14:39 +02:00
commit 1126d96271
174 files changed
+27113

No files matched your search

+582
View File
@@ -0,0 +1,582 @@
<?php
namespace App\Services;
use App\Models\PanelAttribute;
use App\Models\PanelSetting;
use Illuminate\Support\Facades\DB;
/**
* Ustawienia samego panelu (nie danych z AD), trzymane w bazie:
* - ogólne (domyślne zaznaczenia resetu hasła, polityka i generator haseł,
* klucz szyfrujący) -> tabela `panel_settings`, jeden wiersz 'panel'
* (klucz -> wartość JSON),
* - definicje atrybutów AD (kolumny listy + pola profilu) -> tabela
* `panel_attributes`, po jednym wierszu na atrybut, kolejność wg `position`.
*/
class PanelSettingsService
{
/**
* Kategorie (zakładki) w oknie "Podgląd / Edycja" użytkownika, do których
* przypisywane są poszczególne atrybuty. Klucz = identyfikator zapisywany
* w konfiguracji, wartość = etykieta wyświetlana w interfejsie.
*/
public const ATTRIBUTE_CATEGORIES = [
'general' => 'Ogólne',
'org' => 'Organizacja',
'address' => 'Adres i lokalizacja',
'hr' => 'Kadry i chmura',
'account' => 'Konto AD',
];
/**
* Pozycje "na stałe": sAMAccountName (identyfikuje wiersze) i DN (ścieżka
* obiektu). Nie można ich usunąć ani zmienić ich klucza / atrybutu AD.
* Etykieta, kategoria i widoczność jako kolumna są konfigurowalne, ale typ
* jest wymuszony ("systemowy") - takich pozycji panel nie edytuje.
*
* @var list<string>
*/
public const SYSTEM_ATTRIBUTE_KEYS = ['login', 'distinguishedName'];
/**
* Kolumny wyliczane przez panel (nie są to atrybuty AD): status konta
* oraz wygasanie hasła. W zakładce "Atrybuty" można nimi zarządzać jak
* kolumnami (kolejność, widoczność), ale nie da się ich edytować, usunąć
* ani zmienić ich klucza / typu.
*
* @var list<string>
*/
public const COMPUTED_ATTRIBUTE_KEYS = ['status', 'password_expiry'];
/**
* Pozycje "na stałe", ale będące realnymi atrybutami AD: klucza ani nazwy
* atrybutu AD nie da się zmienić i nie można ich usunąć, natomiast kategorię
* ustawia się normalnie. Wartość mapy to kanoniczna nazwa atrybutu AD
* (zapis / odczyt ma dedykowaną obsługę w kontrolerze - accountExpires to
* Windows FileTime, nie zwykły string). Typ takiej pozycji jest wymuszony.
*
* @var array<string, string>
*/
public const FIXED_ATTRIBUTE_KEYS = ['account_expiry' => 'accountExpires'];
/**
* Wymuszony typ pozycji "na stałe" (patrz FIXED_ATTRIBUTE_KEYS).
*
* @var array<string, string>
*/
public const FIXED_ATTRIBUTE_TYPES = ['account_expiry' => 'date'];
/**
* Typ atrybutu w oknie "Podgląd / Edycja". Zastępuje dawne osobne flagi
* "edytowalny" i "szyfrowany" - jeden wybór z listy w zakładce "Atrybuty".
*
* Edytowalne: text | number | date | encrypted
* Nieedytowalne: system | account_status | password_expiry
*
* @var array<string, string>
*/
public const ATTRIBUTE_TYPE_LABELS = [
'text' => 'Tekst',
'number' => 'Liczba',
'date' => 'Data',
'encrypted' => 'Tekst szyfrowany',
'system' => 'Systemowy',
'account_status' => 'Status konta',
'password_expiry' => 'Wygaśnięcie hasła',
];
/** Typy, które użytkownik może wybrać dla zwykłego atrybutu AD. */
public const EDITABLE_ATTRIBUTE_TYPES = ['text', 'number', 'date', 'encrypted'];
/** Wszystkie dopuszczalne wartości typu. */
public const ATTRIBUTE_TYPES = [
'text', 'number', 'date', 'encrypted', 'system', 'account_status', 'password_expiry',
];
/** Klucz wiersza w tabeli `panel_settings`, pod którym zapisujemy całą strukturę. */
protected const STORE_KEY = 'panel';
public function __construct()
{
//
}
/**
* Domyślna, wbudowana konfiguracja panelu.
*
* @return array<string, mixed>
*/
public function defaults(): array
{
return [
'reset_password' => [
'must_change_password' => true,
'unlock_account' => true,
],
'password_policy' => [
// Maksymalny wiek hasła w dniach (domyślne AD = 42).
'max_age_days' => 42,
// Generator haseł w panelu (nie zmienia polityki w AD).
'generator_length' => 16,
'generator_symbols' => true,
],
'encryption' => [
// Klucz (hasło) do odszyfrowania / zaszyfrowania atrybutów
// oznaczonych jako "szyfrowany". Puste = szyfrowanie wyłączone.
'key' => '',
],
'audit' => [
// Wymuś podanie powodu przy akcjach niszczących (wyłącz / usuń /
// przenieś / offboarding). Wpis w dzienniku powstaje zawsze.
'require_reason' => false,
],
];
}
/**
* Czy panel wymaga podania powodu przy akcjach niszczących.
*/
public function requireReason(): bool
{
return (bool) ($this->all()['audit']['require_reason'] ?? false);
}
/**
* Maksymalny wiek hasła w dniach (0 = brak polityki wygasania).
*/
public function passwordMaxAgeDays(): int
{
$value = (int) ($this->all()['password_policy']['max_age_days'] ?? 42);
return max(0, $value);
}
/**
* Ustawienia generatora haseł (tylko w panelu - nie dotyka polityki AD).
*
* @return array{length:int,symbols:bool}
*/
public function passwordGenerator(): array
{
$policy = $this->all()['password_policy'] ?? [];
return [
'length' => max(8, min(128, (int) ($policy['generator_length'] ?? 16))),
'symbols' => (bool) ($policy['generator_symbols'] ?? true),
];
}
/**
* Klucz szyfrujący atrybuty (pusty = szyfrowanie wyłączone).
*/
public function encryptionKey(): string
{
return trim((string) ($this->all()['encryption']['key'] ?? ''));
}
/**
* Wbudowana lista atrybutów widocznych w panelu. Stanowi punkt wyjścia
* dla konfiguracji użytkownika (zakładka "Atrybuty").
*
* @return list<array<string, mixed>>
*/
protected function defaultAttributes(): array
{
return [
// --- Tożsamość (domyślnie widoczne kolumny listy) --------------
['key' => 'login', 'label' => 'sAMAccountName', 'ldap' => 'sAMAccountName', 'category' => 'account', 'type' => 'system', 'column' => true],
['key' => 'displayName', 'label' => 'Imię i Nazwisko', 'ldap' => 'displayName', 'category' => 'general', 'type' => 'text', 'column' => true],
['key' => 'email', 'label' => 'Adres e-mail', 'ldap' => 'mail', 'category' => 'general', 'type' => 'text', 'column' => true],
// --- Ogólne -----------------------------------------------------
['key' => 'firstName', 'label' => 'Imię', 'ldap' => 'givenName', 'category' => 'general', 'type' => 'text', 'column' => false],
['key' => 'lastName', 'label' => 'Nazwisko', 'ldap' => 'sn', 'category' => 'general', 'type' => 'text', 'column' => false],
['key' => 'telephoneNumber', 'label' => 'Telefon stacjonarny', 'ldap' => 'telephoneNumber', 'category' => 'general', 'type' => 'text', 'column' => false],
['key' => 'mobile', 'label' => 'Telefon komórkowy', 'ldap' => 'mobile', 'category' => 'general', 'type' => 'text', 'column' => false],
// --- Organizacja ---------------------------------------------------
['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => false],
['key' => 'department', 'label' => 'Dział / Departament', 'ldap' => 'department', 'category' => 'org', 'type' => 'text', 'column' => false],
['key' => 'company', 'label' => 'Firma / Spółka', 'ldap' => 'company', 'category' => 'org', 'type' => 'text', 'column' => false],
['key' => 'manager', 'label' => 'Kierownik', 'ldap' => 'manager', 'category' => 'org', 'type' => 'text', 'column' => false],
// --- Adres i lokalizacja ----------------------------------------
['key' => 'co', 'label' => 'Kraj (co)', 'ldap' => 'co', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'st', 'label' => 'Województwo (st)', 'ldap' => 'st', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'l', 'label' => 'Miasto (l)', 'ldap' => 'l', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'postalCode', 'label' => 'Kod pocztowy', 'ldap' => 'postalCode', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'streetAddress', 'label' => 'Ulica i numer domu', 'ldap' => 'streetAddress', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'roomNumber', 'label' => 'Numer pokoju / biurka', 'ldap' => 'roomNumber', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'physicalDeliveryOfficeName', 'label' => 'Główna lokalizacja', 'ldap' => 'physicalDeliveryOfficeName', 'category' => 'address', 'type' => 'text', 'column' => false],
// --- Kadry i chmura -------------------------------------------------
['key' => 'employeeID', 'label' => 'ID pracownika (Snipe-IT)', 'ldap' => 'employeeID', 'category' => 'hr', 'type' => 'text', 'column' => false],
['key' => 'employeeNumber', 'label' => 'Kod pracownika (PIN)', 'ldap' => 'employeeNumber', 'category' => 'hr', 'type' => 'text', 'column' => false],
['key' => 'microsoft', 'label' => 'Microsoft Cloud', 'ldap' => 'msDS-cloudExtensionAttribute1', 'category' => 'hr', 'type' => 'encrypted', 'column' => false],
['key' => 'google', 'label' => 'Google Cloud', 'ldap' => 'msDS-cloudExtensionAttribute2', 'category' => 'hr', 'type' => 'encrypted', 'column' => false],
// --- Konto AD ----------------------------------------------------
['key' => 'distinguishedName', 'label' => 'Ścieżka (DN)', 'ldap' => 'distinguishedName', 'category' => 'account', 'type' => 'system', 'column' => false],
['key' => 'account_expiry', 'label' => 'Wygasanie konta', 'ldap' => 'accountExpires', 'category' => 'account', 'type' => 'date', 'column' => false],
// --- Kolumny wyliczane (nie są atrybutami AD) ---------------------
['key' => 'status', 'label' => 'Status konta', 'ldap' => 'status', 'category' => 'account', 'type' => 'account_status', 'column' => true],
['key' => 'password_expiry', 'label' => 'Wygasanie hasła', 'ldap' => 'password_expiry', 'category' => 'account', 'type' => 'password_expiry', 'column' => true],
];
}
/**
* Ustawienia ogólne (bez definicji atrybutów - te ma `attributes()`).
*
* @return array<string, mixed>
*/
public function all(): array
{
return array_replace_recursive($this->defaults(), $this->readStored());
}
/**
* Surowy wiersz 'panel' z tabeli `panel_settings` (bez scalania z
* domyślnymi). Gdy tabeli jeszcze nie ma (np. przed migracją) - pusto.
*
* @return array<string, mixed>
*/
protected function readStored(): array
{
try {
$value = PanelSetting::query()->find(self::STORE_KEY)?->value;
return is_array($value) ? $value : [];
} catch (\Throwable) {
return [];
}
}
public function save(array $settings): void
{
PanelSetting::query()->updateOrCreate(
['key' => self::STORE_KEY],
['value' => $settings],
);
}
// =====================================================================
// Atrybuty / kolumny
// =====================================================================
/**
* Klucze, których nie wolno usunąć (systemowe + wyliczane).
*
* @return list<string>
*/
protected function lockedKeys(): array
{
return array_merge(
self::SYSTEM_ATTRIBUTE_KEYS,
self::COMPUTED_ATTRIBUTE_KEYS,
array_keys(self::FIXED_ATTRIBUTE_KEYS),
);
}
/**
* Znormalizowana lista wszystkich zdefiniowanych atrybutów, z gwarancją
* obecności pozycji systemowych i wyliczanych oraz bez duplikatów kluczy.
*
* @return list<array{key:string,label:string,ldap:string,category:string,type:string,input:string,editable:bool,column:bool,system:bool,computed:bool,fixed:bool,encrypted:bool}>
*/
public function attributes(): array
{
// Zapisana konfiguracja z tabeli `panel_attributes`; brak wierszy =
// użytkownik jeszcze nic nie zmieniał -> wbudowana lista domyślna.
$list = $this->storedAttributeRows() ?: $this->defaultAttributes();
$normalized = [];
$seen = [];
foreach ($list as $item) {
if (! is_array($item)) {
continue;
}
$attr = $this->normalizeAttribute($item);
if ($attr['key'] === '') {
continue;
}
$lower = strtolower($attr['key']);
if (isset($seen[$lower])) {
continue;
}
$seen[$lower] = true;
$normalized[] = $attr;
}
// Pozycje systemowe i wyliczane muszą istnieć zawsze - odtwarzamy brakujące.
$lockedKeys = $this->lockedKeys();
foreach ($this->defaultAttributes() as $default) {
if (! in_array($default['key'], $lockedKeys, true)) {
continue;
}
if (isset($seen[strtolower($default['key'])])) {
continue;
}
$normalized[] = $this->normalizeAttribute($default);
$seen[strtolower($default['key'])] = true;
}
return $normalized;
}
/**
* Wiersze z tabeli `panel_attributes` w kolejności `position`, sprowadzone
* do kształtu przyjmowanego przez normalizeAttribute(). Brak tabeli
* (np. przed migracją) traktujemy jak brak konfiguracji.
*
* @return list<array<string, mixed>>
*/
protected function storedAttributeRows(): array
{
try {
return PanelAttribute::query()
->orderBy('position')
->orderBy('id')
->get()
->map(fn (PanelAttribute $a) => [
'key' => $a->key,
'label' => $a->label,
'ldap' => $a->ldap,
'category' => $a->category,
'type' => $a->type,
'regex' => $a->regex,
'required' => $a->required,
'help' => $a->help,
'column' => $a->is_column,
])
->all();
} catch (\Throwable) {
return [];
}
}
/**
* Zapisuje przesłaną listę atrybutów, wymuszając reguły dla pozycji
* systemowych i wyliczanych oraz odtwarzając te, które ktoś próbował usunąć.
*
* @param iterable<array<string, mixed>> $attributes
*/
public function saveAttributes(iterable $attributes): void
{
$lockedDefaults = [];
foreach ($this->defaultAttributes() as $default) {
if (in_array($default['key'], $this->lockedKeys(), true)) {
$lockedDefaults[$default['key']] = $this->normalizeAttribute($default);
}
}
$clean = [];
$seen = [];
foreach ($attributes as $item) {
if (! is_array($item)) {
continue;
}
$attr = $this->normalizeAttribute($item);
if ($attr['key'] === '') {
continue;
}
$lower = strtolower($attr['key']);
if (isset($seen[$lower])) {
continue;
}
if (isset($lockedDefaults[$attr['key']])) {
$def = $lockedDefaults[$attr['key']];
// Typ, editable/encrypted oraz ldap dla kolumn wyliczanych /
// "na stałe" wymusza już normalizeAttribute po kluczu. Zostaje
// przywrócenie nazwy atrybutu AD dla pozycji systemowych oraz
// stałej kategorii dla kolumn wyliczanych.
$attr['ldap'] = $def['ldap'];
if ($def['computed']) {
$attr['category'] = $def['category'];
}
}
$seen[$lower] = true;
$clean[] = $attr;
}
foreach ($lockedDefaults as $key => $def) {
if (isset($seen[strtolower($key)])) {
continue;
}
$clean[] = $def;
}
// Nadpisujemy całą tabelę - lista jest krótka, a to najprościej trzyma
// kolejność (position) i usunięcia w zgodzie z wynikiem powyżej.
DB::transaction(function () use ($clean) {
PanelAttribute::query()->delete();
$now = now();
$rows = [];
foreach (array_values($clean) as $i => $attr) {
$rows[] = [
'key' => $attr['key'],
'label' => $attr['label'],
'ldap' => $attr['ldap'],
'category' => $attr['category'],
'type' => $attr['type'],
'regex' => $attr['regex'] !== '' ? $attr['regex'] : null,
'required' => $attr['required'],
'help' => $attr['help'],
'is_column' => $attr['column'],
'position' => $i,
'created_at' => $now,
'updated_at' => $now,
];
}
if ($rows !== []) {
PanelAttribute::query()->insert($rows);
}
});
}
/**
* Atrybuty / kolumny wyliczane oznaczone do wyświetlenia jako kolumna
* listy użytkowników - w kolejności zapisanej w konfiguracji.
*
* @return list<array<string, mixed>>
*/
public function attributeColumns(): array
{
return array_values(array_filter($this->attributes(), static fn ($a) => $a['column']));
}
/**
* Atrybuty pogrupowane wg kategorii (zakładek podglądu użytkownika).
* Pozycje wyliczane są pomijane - nie są polami profilu.
*
* @return array<string, list<array<string, mixed>>>
*/
public function attributesByCategory(): array
{
$grouped = [];
foreach (array_keys(self::ATTRIBUTE_CATEGORIES) as $catId) {
$grouped[$catId] = [];
}
foreach ($this->attributes() as $attr) {
if ($attr['computed']) {
continue;
}
$grouped[$attr['category']][] = $attr;
}
return $grouped;
}
/**
* Wyłącznie atrybuty edytowalne (bez wyliczanych), pogrupowane wg kategorii.
* Zwraca tylko te kategorie, które mają co najmniej jedno pole - to jest
* dokładnie zestaw pól pokazywanych w oknie "Podgląd / Edycja".
*
* @return array<string, list<array<string, mixed>>>
*/
public function editableAttributesByCategory(): array
{
$grouped = [];
foreach ($this->attributes() as $attr) {
if ($attr['computed'] || ! $attr['editable']) {
continue;
}
$grouped[$attr['category']][] = $attr;
}
$ordered = [];
foreach (array_keys(self::ATTRIBUTE_CATEGORIES) as $catId) {
if (! empty($grouped[$catId])) {
$ordered[$catId] = $grouped[$catId];
}
}
return $ordered;
}
/**
* @return array<string, string>
*/
public function categories(): array
{
return self::ATTRIBUTE_CATEGORIES;
}
/**
* @param array<string, mixed> $a
* @return array{key:string,label:string,ldap:string,category:string,type:string,input:string,editable:bool,column:bool,system:bool,computed:bool,fixed:bool,encrypted:bool}
*/
protected function normalizeAttribute(array $a): array
{
$key = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($a['key'] ?? ''));
$ldap = trim((string) ($a['ldap'] ?? ''));
$category = (string) ($a['category'] ?? 'general');
$computed = in_array($key, self::COMPUTED_ATTRIBUTE_KEYS, true);
$system = in_array($key, self::SYSTEM_ATTRIBUTE_KEYS, true);
$fixedLdap = self::FIXED_ATTRIBUTE_KEYS[$key] ?? null;
// Typ pozycji wbudowanych jest wymuszony; dla zwykłych atrybutów
// pochodzi z wyboru użytkownika (lista w zakładce "Atrybuty") i musi
// być jednym z typów edytowalnych.
if ($system) {
$type = 'system';
} elseif ($computed) {
$type = $key === 'status' ? 'account_status' : 'password_expiry';
} elseif ($fixedLdap !== null) {
$type = self::FIXED_ATTRIBUTE_TYPES[$key] ?? 'text';
} else {
$type = (string) ($a['type'] ?? 'text');
if (! in_array($type, self::EDITABLE_ATTRIBUTE_TYPES, true)) {
$type = 'text';
}
}
$editable = in_array($type, self::EDITABLE_ATTRIBUTE_TYPES, true);
return [
'key' => $key,
'label' => trim((string) ($a['label'] ?? '')) ?: $key,
// Klucz i atrybut AD pozycji "na stałe" są niezmienne; kolumny
// wyliczane nie są atrybutami AD.
'ldap' => $fixedLdap ?? ($computed ? $key : ($ldap !== '' ? $ldap : $key)),
'category' => array_key_exists($category, self::ATTRIBUTE_CATEGORIES) ? $category : 'general',
'type' => $type,
// Rodzaj pola formularza w oknie "Podgląd / Edycja".
'input' => in_array($type, ['number', 'date'], true) ? $type : 'text',
'editable' => $editable,
// Reguły walidacji pola profilu - tylko dla atrybutów edytowalnych.
'regex' => $editable ? trim((string) ($a['regex'] ?? '')) : '',
'required' => $editable ? filter_var($a['required'] ?? false, FILTER_VALIDATE_BOOL) : false,
'help' => $editable ? trim((string) ($a['help'] ?? '')) : '',
'column' => filter_var($a['column'] ?? false, FILTER_VALIDATE_BOOL),
'system' => $system,
'computed' => $computed,
'fixed' => $fixedLdap !== null,
'encrypted' => $type === 'encrypted',
];
}
}