Compare commits

..

10 Commits

Author SHA1 Message Date
cf25f08419 Pfade angepasst 2025-12-17 16:11:13 +01:00
c01d8b9b12 Log-Viewer aktualisiert 2025-12-17 16:06:10 +01:00
52223ba1e8 Log-Viewer aktualisiert 2025-12-17 15:50:05 +01:00
2536d66c3e Log-Viewer aktualisiert 2025-12-17 15:39:27 +01:00
9f0e534e20 develop (#28)
Co-authored-by: tg95 <tg95@noreply.localhost>
Co-authored-by: Taarly <lownslow.music@gmail.com>
Co-authored-by: taarly <lownslow.music@gmail.com>
Co-authored-by: ViperioN1339 <stezel1989@outlook.de>
Co-authored-by: MuchenTuchen31 <yasin.mine31@gmail.com>
Reviewed-on: https://git.eckertplayground.de/taarly/PHP_AdminTool_Projekt/pulls/28
Co-authored-by: blaerf <blaerf@gmx.de>
Co-committed-by: blaerf <blaerf@gmx.de>
2025-12-17 14:28:04 +00:00
7911740ad9 revert ab59eb9567
revert added another page to create users
2025-12-13 14:13:52 +00:00
ab59eb9567 added another page to create users 2025-12-13 14:41:31 +01:00
d2fcf57559 readme aktualisiert und aufgaben hinzugefügt / neu verteilt 2025-12-10 09:57:54 +00:00
1a4dfb0df9 README.md aktualisiert 2025-12-10 09:56:22 +00:00
c490d38457 develop (#24)
Reviewed-on: https://git.eckertplayground.de/taarly/PHP_AdminTool_Projekt/pulls/24
Co-authored-by: blaerf <blaerf@gmx.de>
Co-committed-by: blaerf <blaerf@gmx.de>
2025-12-05 09:20:21 +00:00
25 changed files with 2270 additions and 104 deletions

View File

@ -65,8 +65,11 @@ Der komplette Ablauf ist im [Gitea-Workflow](https://git.eckertplayground.de/taa
| Benutzer und Gruppen über LDAP anzeigen | Jens E (@blaerf), Stefan W (@viperion) |
| SNMP Serverstatus abfragen / anzeigen |Thomas G (@tg95) |
| PHP-Powershell Anbindung | Marco Z (@Taarly) |
| Powershell Script für einzelne Benutzer und CSV Import | Alle Fisis |
| UI/UX anpassen | Yasin B (@Muchentuchen), Alexander M (@Alexander), Torsten J (@tojacobs) |
| Powershell Script für einzelne Benutzer und CSV Import | Matthias K |
| UI/UX anpassen | Yasin B (@Muchentuchen), Alexander M (@Alexander) |
| Blackbox Testing | Torsten J (@tojacobs) |
**Hinweis:** Die Passwortanforderungen (Mindestlänge, Kategorien, keine Teile des Benutzernamens) werden beim Erstellen validiert. Die Validierung ist in `scripts/powershell/create_users_csv.ps1` implementiert und die Mockup-UI (`docs/Mockup/index.html`) zeigt die Anforderungen und prüft sie clientseitig.
---
@ -81,6 +84,35 @@ Dieser Bereich muss von allen Entwicklern gelesen werden, bevor am Projekt gearb
---
## PowerShell Integration (Benutzererstellung)
Die Weboberfläche nutzt PowerShell-Skripte, um Active Directory Benutzer anzulegen. Damit dies funktioniert, sind folgende Voraussetzungen erforderlich:
- Der Webserver läuft auf Windows und PHP kann PowerShell ausführen (`powershell` oder `pwsh`).
- Die PowerShell-Module `ActiveDirectory` müssen installiert (RSAT) und verfügbar sein.
- Der Benutzer, unter dem der Webserver läuft, muss ausreichende Rechte besitzen, um `New-ADUser` und `Add-ADGroupMember` auszuführen.
- Im `config/config.php` kann `powershell.dry_run` auf `true` gesetzt werden, um Tests ohne Änderungen durchzuführen.
Konfigurationsoptionen (in `config/config.php`):
- `powershell.exe`: Name oder Pfad zur PowerShell-Executable (standard `powershell`).
- `powershell.script_dir`: Pfad zu den PowerShell-Skripten (standard `scripts/powershell`).
- `powershell.execution_policy`: Auszuführende ExecutionPolicy (z. B. `Bypass`).
- `powershell.dry_run`: Wenn `true`, werden keine echten AD-Änderungen durchgeführt; das Skript meldet nur, was es tun würde.
Die grundlegende Funktionalität wurde mit folgenden Komponenten implementiert:
- `public/api/create_user.php`: API-Endpoint zur Erstellung eines einzelnen Benutzers.
- `public/api/create_users_csv.php`: API-Endpoint zur Erstellung mehrerer Benutzer aus CSV.
- `scripts/powershell/create_user.ps1`: PowerShell-Skript zum Erstellen eines einzelnen Benutzers.
- `scripts/powershell/create_users_csv.ps1`: PowerShell-Skript zum Erstellen mehrerer Benutzer aus CSV.
- `scripts/powershell/check_environment.ps1`: Prüft, ob `ActiveDirectory`-Modul vorhanden ist und zeigt die ausführende Identität an.
API endpoints:
- `public/api/powershell_check.php`: Ruft `check_environment.ps1` auf und gibt ein JSON-Objekt mit `actor`, `module_installed`, `can_new_aduser` zurück.
Bitte testen zuerst mit `powershell.dry_run = true` und prüfen sie die resultierenden Meldungen in UI.
## Mitwirken
Wer etwas ändern oder erweitern möchte:

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Services\Logging\LoggingService;
use App\Services\Logging\LogViewerService;
/**
* Controller für den Log Viewer (read-only).
*/
class LogViewerController
{
/** @var array<string, mixed> */
private array $config;
private LoggingService $logger;
private LogViewerService $logViewer;
/**
* @param array<string, mixed> $config
*/
public function __construct(array $config)
{
$this->config = $config;
$loggingConfig = $config['logging'] ?? [];
$this->logger = new LoggingService($loggingConfig);
$this->logViewer = new LogViewerService($loggingConfig);
}
/**
* Zeigt den Log Viewer an.
*
* Erwartet optionale GET-Parameter:
* - file (Dateiname, z.B. app.log)
* - level (DEBUG|INFO|WARNING|ERROR)
* - q (Suche)
* - lines (Anzahl Zeilen, Default 200)
*
* @return array<string, mixed>
*/
public function show(): array
{
$files = $this->logViewer->listLogFiles();
$selectedFile = (string)($_GET['file'] ?? '');
if ($selectedFile === '' && isset($files[0]['name'])) {
$selectedFile = (string)$files[0]['name'];
}
$level = (string)($_GET['level'] ?? '');
$level = strtoupper(trim($level));
if ($level === '') {
$level = '';
}
$q = (string)($_GET['q'] ?? '');
$q = trim($q);
$lines = (int)($_GET['lines'] ?? 200);
if ($lines <= 0) {
$lines = 200;
}
if ($lines > 2000) {
$lines = 2000;
}
$error = null;
$fileMeta = null;
$entries = [];
try {
if ($selectedFile !== '') {
$fileMeta = $this->logViewer->getFileMeta($selectedFile);
$entries = $this->logViewer->getEntries(
$selectedFile,
$lines,
$level !== '' ? $level : null,
$q !== '' ? $q : null
);
if ($fileMeta === null) {
$error = 'Die ausgewählte Log-Datei ist nicht verfügbar.';
$entries = [];
}
} else {
$error = 'Es wurde keine Log-Datei gefunden.';
}
} catch (\Throwable $ex) {
$this->logger->logException(
'LogViewerController: Fehler beim Laden der Logs.',
$ex,
[
'route' => 'logs',
'file' => $selectedFile,
]
);
$error = 'Technischer Fehler beim Laden der Logs. Details stehen im app.log.';
}
$viewPath = __DIR__ . '/../../public/views/logs.php';
return [
'view' => $viewPath,
'data' => [
'loginPage' => false,
'files' => $files,
'selectedFile' => $selectedFile,
'fileMeta' => $fileMeta,
'entries' => $entries,
'filterLevel' => $level,
'searchQuery' => $q,
'lines' => $lines,
'error' => $error,
],
'pageTitle' => 'Logs',
'activeMenu' => 'logs',
];
}
}

View File

@ -100,4 +100,49 @@ class UserManagementController
'activeMenu' => 'users',
];
}
/**
* Zeigt die Seite zum Erstellen von Benutzern (Einzel/CSV).
*
* @return array<string, mixed>
*/
public function create(): array
{
$viewPath = __DIR__ . '/../../public/views/createuser.php';
// Use session flash messages if available
$error = null;
$success = null;
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_start();
}
if (isset($_SESSION['flash_error'])) {
$error = $_SESSION['flash_error'];
unset($_SESSION['flash_error']);
}
if (isset($_SESSION['flash_success'])) {
$success = $_SESSION['flash_success'];
unset($_SESSION['flash_success']);
}
$csvDetails = null;
if (isset($_SESSION['csv_details'])) {
$csvDetails = $_SESSION['csv_details'];
unset($_SESSION['csv_details']);
}
$powershellDryRun = $this->config['powershell']['dry_run'] ?? false;
return [
'view' => $viewPath,
'data' => [
'error' => $error,
'success' => $success,
'loginPage' => false,
'csvDetails' => $csvDetails,
'powershellDryRun' => $powershellDryRun,
],
'pageTitle' => 'Benutzer erstellen',
'activeMenu' => 'createuser',
];
}
}

View File

@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
namespace App\Services\Logging;
/**
* LogViewerService
*
* - Listet Log-Dateien im konfigurierten Logging-Verzeichnis.
* - Liest performant die letzten N Zeilen (Tail), ohne komplette Datei zu laden.
* - Parst das LoggingService-Format:
* "[YYYY-MM-DD HH:MM:SS] LEVEL message {json}"
*/
class LogViewerService
{
private string $logDir;
/**
* @param array<string, mixed> $loggingConfig Teilkonfiguration "logging" aus config.php
*/
public function __construct(array $loggingConfig)
{
$baseDir = $loggingConfig['log_dir'] ?? (__DIR__ . '/../../../public/logs');
$this->logDir = rtrim((string)$baseDir, DIRECTORY_SEPARATOR);
}
/**
* @return array<int, array{name:string, size:int, mtime:int}>
*/
public function listLogFiles(): array
{
$result = [];
if (is_dir($this->logDir) === false) {
return $result;
}
$entries = @scandir($this->logDir);
if ($entries === false) {
return $result;
}
foreach ($entries as $name) {
if ($name === '.' || $name === '..') {
continue;
}
// Nur normale Dateien, keine Unterordner.
$fullPath = $this->logDir . DIRECTORY_SEPARATOR . $name;
if (is_file($fullPath) === false) {
continue;
}
// Safety: nur "logartige" Dateien anzeigen.
$lower = strtolower($name);
if (
str_ends_with($lower, '.log') === false
&& str_ends_with($lower, '.txt') === false
) {
continue;
}
$size = @filesize($fullPath);
$mtime = @filemtime($fullPath);
$result[] = [
'name' => $name,
'size' => is_int($size) ? $size : 0,
'mtime' => is_int($mtime) ? $mtime : 0,
];
}
// Neueste zuerst
usort(
$result,
static function (array $a, array $b): int {
return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
}
);
return $result;
}
/**
* Liefert Metadaten zur ausgewählten Datei (oder null wenn ungültig).
*
* @return array{name:string, size:int, mtime:int}|null
*/
public function getFileMeta(string $fileName): ?array
{
$path = $this->resolveLogFilePath($fileName);
if ($path === null) {
return null;
}
$size = @filesize($path);
$mtime = @filemtime($path);
return [
'name' => basename($path),
'size' => is_int($size) ? $size : 0,
'mtime' => is_int($mtime) ? $mtime : 0,
];
}
/**
* @param string $fileName
* @param int $maxLines
* @param string|null $levelFilter z.B. "ERROR"|"WARNING"|"INFO"|"DEBUG"|null
* @param string|null $search Freitextsuche in message/context/raw
*
* @return array<int, array{
* ts:string|null,
* level:string|null,
* message:string,
* context:array<string,mixed>|null,
* raw:string
* }>
*/
public function getEntries(string $fileName, int $maxLines = 200, ?string $levelFilter = null, ?string $search = null): array
{
$path = $this->resolveLogFilePath($fileName);
if ($path === null) {
return [];
}
$lines = $this->tailLines($path, $maxLines);
$entries = [];
foreach ($lines as $line) {
$parsed = $this->parseLine($line);
if ($levelFilter !== null && $levelFilter !== '') {
$lvl = strtoupper((string)($parsed['level'] ?? ''));
if ($lvl !== strtoupper($levelFilter)) {
continue;
}
}
if ($search !== null && $search !== '') {
$haystack = $parsed['raw'] . ' ' . $parsed['message'];
if (is_array($parsed['context'])) {
$json = json_encode(
$parsed['context'],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR
);
if ($json !== false) {
$haystack .= ' ' . $json;
}
}
if (mb_stripos($haystack, $search) === false) {
continue;
}
}
$entries[] = $parsed;
}
return $entries;
}
/**
* @return string|null Vollständiger Pfad oder null, wenn ungültig/Traversal
*/
private function resolveLogFilePath(string $fileName): ?string
{
$fileName = trim($fileName);
if ($fileName === '') {
return null;
}
// Keine Pfade erlauben, nur Dateiname
$fileName = basename($fileName);
$candidate = $this->logDir . DIRECTORY_SEPARATOR . $fileName;
$realDir = realpath($this->logDir);
$realFile = realpath($candidate);
if ($realDir === false || $realFile === false) {
return null;
}
// Muss innerhalb des Log-Verzeichnisses liegen
$realDirNorm = rtrim($realDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$realFileNorm = $realFile;
if (str_starts_with($realFileNorm, $realDirNorm) === false) {
return null;
}
if (is_file($realFileNorm) === false) {
return null;
}
return $realFileNorm;
}
/**
* Tail-Implementierung: liest die letzten $maxLines Zeilen.
*
* @return array<int, string>
*/
private function tailLines(string $filePath, int $maxLines): array
{
$maxLines = max(1, min(2000, $maxLines));
$fh = @fopen($filePath, 'rb');
if ($fh === false) {
return [];
}
$chunkSize = 8192;
$buffer = '';
$pos = -1;
$linesFound = 0;
// ans Ende springen
@fseek($fh, 0, SEEK_END);
$fileSize = (int)@ftell($fh);
if ($fileSize <= 0) {
@fclose($fh);
return [];
}
while ($linesFound <= $maxLines && -$pos < $fileSize) {
$readSize = $chunkSize;
if (-$pos + $chunkSize > $fileSize) {
$readSize = $fileSize - (-$pos);
}
@fseek($fh, $pos - $readSize + 1, SEEK_END);
$chunk = (string)@fread($fh, $readSize);
$buffer = $chunk . $buffer;
$linesFound = substr_count($buffer, "\n");
$pos -= $readSize;
}
@fclose($fh);
$lines = preg_split("/\r\n|\n|\r/", $buffer);
if (is_array($lines) === false) {
return [];
}
// ggf. letzte leere Zeile raus
if (end($lines) === '') {
array_pop($lines);
}
// letzte maxLines
$lines = array_slice($lines, -$maxLines);
// Leere Zeilen am Anfang/Ende tolerieren, aber nicht komplett aufblasen
$clean = [];
foreach ($lines as $line) {
$line = (string)$line;
if ($line === '') {
continue;
}
$clean[] = $line;
}
return $clean;
}
/**
* @return array{
* ts:string|null,
* level:string|null,
* message:string,
* context:array<string,mixed>|null,
* raw:string
* }
*/
private function parseLine(string $line): array
{
$raw = $line;
$ts = null;
$level = null;
$message = $line;
$context = null;
// Basis: [timestamp] LEVEL ...
if (preg_match('/^\[(?<ts>[0-9\-:\s]{19})]\s+(?<lvl>[A-Za-z]+)\s+(?<rest>.*)$/', $line, $m) === 1) {
$ts = (string)$m['ts'];
$level = (string)$m['lvl'];
$rest = (string)$m['rest'];
// Versuch: Context ist am Ende ein JSON-Objekt, das mit "{" beginnt und mit "}" endet.
$ctxCandidate = null;
$lastBracePos = strrpos($rest, '{');
if ($lastBracePos !== false) {
$maybeJson = substr($rest, $lastBracePos);
$maybeJson = trim($maybeJson);
if ($maybeJson !== '' && str_starts_with($maybeJson, '{') && str_ends_with($maybeJson, '}')) {
$decoded = json_decode($maybeJson, true);
if (is_array($decoded)) {
$ctxCandidate = $decoded;
$rest = trim(substr($rest, 0, $lastBracePos));
}
}
}
$context = $ctxCandidate;
$message = $rest;
}
if ($level === null) {
$upper = strtoupper($raw);
if (str_starts_with($upper, 'PHP WARNING') || str_starts_with($upper, 'WARNING')) {
$level = 'WARNING';
} elseif (str_starts_with($upper, 'PHP NOTICE') || str_starts_with($upper, 'NOTICE')) {
$level = 'INFO';
} elseif (str_starts_with($upper, 'PHP FATAL') || str_contains($upper, 'FATAL ERROR') || str_starts_with($upper, 'UNCAUGHT')) {
$level = 'ERROR';
} else {
$level = 'UNKNOWN';
}
}
return [
'ts' => $ts,
'level' => $level,
'message' => $message,
'context' => $context,
'raw' => $raw,
];
}
}

View File

@ -60,4 +60,16 @@ return [
// Minimale Stufe: debug, info, warning, error
'min_level' => 'info',
],
'powershell' => [
// Executable name: 'powershell' on Windows, 'pwsh' for PowerShell core.
'exe' => 'powershell',
// Script directory where the PS1 scripts live (relative to config dir)
'script_dir' => __DIR__ . '/../scripts/powershell',
// Execution policy to pass to the PowerShell invocation
'execution_policy' => 'Bypass',
// Default OU. IIS only has write access there.
'default_ou' => 'OU=WebAppUsers,DC=ITFA-PROJ-DOM,DC=local',
// For testing; if true, the scripts will run in dry-run mode (no real AD changes)
'dry_run' => false,
],
];

View File

@ -262,6 +262,10 @@
background-color: var(--color-secondary);
}
/* Password hint and invalid input styles */
.password-hint { display:block; margin-top: 6px; font-size: 0.9rem; color: #6c757d; }
.invalid { border: 1px solid #c0152f; background-color: rgba(192,21,47,0.04); }
</style>
</head>
<body>
@ -280,6 +284,7 @@
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
<small class="text-muted password-hint">Das Passwort muss mindestens 7 Zeichen lang sein, darf keine größeren Teile des Benutzernamens enthalten und muss Zeichen aus mindestens 3 von 4 Kategorien enthalten: Großbuchstaben, Kleinbuchstaben, Ziffern, Sonderzeichen.</small>
</div>
<div class="form-group">
@ -377,14 +382,60 @@
}
}
function validatePasswordJS(password, sam) {
const errors = [];
if (!password || password.length < 7) {
errors.push('Passwort muss mindestens 7 Zeichen lang sein.');
}
let categories = 0;
if (/[A-Z]/.test(password)) categories++;
if (/[a-z]/.test(password)) categories++;
if (/\d/.test(password)) categories++;
if (/[^A-Za-z0-9]/.test(password)) categories++;
if (categories < 3) {
errors.push('Passwort muss Zeichen aus mindestens 3 von 4 Kategorien enthalten.');
}
if (sam) {
const pwLower = password.toLowerCase();
const samLower = sam.toLowerCase();
if (pwLower.includes(samLower)) {
errors.push('Passwort darf den Benutzernamen nicht enthalten.');
} else {
const minLen = 4;
if (samLower.length >= minLen) {
outer: for (let len = minLen; len <= samLower.length; len++) {
for (let s = 0; s <= samLower.length - len; s++) {
const sub = samLower.substr(s, len);
if (pwLower.includes(sub)) {
errors.push('Passwort darf keine größeren Teile des Benutzernamens enthalten.');
break outer;
}
}
}
}
}
}
return errors;
}
document.getElementById('adForm').addEventListener('submit', (e) => {
e.preventDefault();
const firstname = document.getElementById('firstname').value.trim();
const lastname = document.getElementById('lastname').value.trim();
const samGuess = (firstname + lastname).replace(/\s+/g, '');
const password = document.getElementById('password').value;
const pwErrors = validatePasswordJS(password, samGuess);
if (pwErrors.length > 0) {
alert('Passwort ungültig:\n' + pwErrors.join('\n'));
return;
}
const formData = {
firstname: document.getElementById('firstname').value,
lastname: document.getElementById('lastname').value,
firstname: firstname,
lastname: lastname,
group: document.getElementById('group').value,
password: document.getElementById('password').value,
password: password,
customFields: {}
};
@ -442,6 +493,9 @@
// Datenzeilen erstellen (mit editierbaren Inputs)
tableBody.innerHTML = '';
const pwdHeaderIndex = headers.findIndex(h => /pass(word)?/i.test(h));
const samHeaderIndex = headers.findIndex(h => /(sam(accountname)?)|samaccountname/i.test(h));
let foundInvalid = false;
for (let i = 1; i < csvData.length; i++) {
const row = csvData[i];
const tr = document.createElement('tr');
@ -454,6 +508,10 @@
input.dataset.col = index;
input.addEventListener('input', (e) => {
csvData[i][index] = e.target.value;
// live re-validate if this is password or sam column
if (index === pwdHeaderIndex || index === samHeaderIndex) {
validateCsvPasswords(headers);
}
});
td.appendChild(input);
tr.appendChild(td);
@ -461,10 +519,53 @@
tableBody.appendChild(tr);
}
// Validate password column if present
function validateCsvPasswords(headersLocal) {
const pwdIdx = pwdHeaderIndex;
const samIdx = samHeaderIndex;
foundInvalid = false;
// Clear previous highlights/messages
const rows = tableBody.querySelectorAll('tr');
rows.forEach((r, idx) => {
r.querySelectorAll('input').forEach(inp => inp.classList.remove('invalid'));
});
if (pwdIdx >= 0) {
for (let r = 0; r < rows.length; r++) {
const inputs = rows[r].querySelectorAll('input');
const pwdVal = inputs[pwdIdx] ? inputs[pwdIdx].value : '';
const samVal = (samIdx >= 0 && inputs[samIdx]) ? inputs[samIdx].value : '';
const errs = validatePasswordJS(pwdVal, samVal);
if (errs.length > 0) {
foundInvalid = true;
if (inputs[pwdIdx]) inputs[pwdIdx].classList.add('invalid');
}
}
}
const previewInfo = previewDiv.querySelector('.preview-info');
if (!previewInfo) return;
if (foundInvalid) {
previewInfo.innerHTML = '<strong>Hinweis:</strong> Einige Passwörter entsprechen nicht den Anforderungen. Bitte korrigieren Sie diese in der Tabelle bevor Sie importieren.';
} else {
previewInfo.innerHTML = '<strong>Hinweis:</strong> Sie können die Werte direkt in der Tabelle bearbeiten, bevor Sie importieren.';
}
}
// initial validation run
validateCsvPasswords(headers);
previewDiv.style.display = 'block';
}
function importCSVData() {
// Prevent import if any password entries are invalid (highlighted)
const invalids = document.querySelectorAll('#csvTableBody input.invalid');
if (invalids.length > 0) {
alert('Import abgebrochen: Es gibt ungültige Passwörter in der CSV-Vorschau. Bitte korrigieren Sie diese zuerst.');
return;
}
console.log('Importierte CSV-Daten:', csvData);
alert(`${csvData.length - 1} Benutzer erfolgreich importiert!\n\nDaten in der Konsole (F12) einsehen.`);
cancelCSVPreview();

View File

@ -204,6 +204,13 @@
&nbsp;: mixed </span>
</dt>
<dt class="phpdocumentor-table-of-contents__entry -method -public">
<a class="" href="classes/App-Controllers-UserManagementController.html#method_create">create()</a>
<span>
&nbsp;: array&lt;string, mixed&gt; </span>
</dt>
<dd>Zeigt die Seite zum Erstellen von Benutzern (Einzel/CSV).</dd>
<dt class="phpdocumentor-table-of-contents__entry -method -public">
<a class="" href="classes/App-Controllers-UserManagementController.html#method_show">show()</a>
<span>
@ -397,6 +404,45 @@
</article>
<article
class="phpdocumentor-element
-method
-public
"
>
<h4 class="phpdocumentor-element__name" id="method_create">
create()
<a href="classes/App-Controllers-UserManagementController.html#method_create" class="headerlink"><i class="fas fa-link"></i></a>
</h4>
<aside class="phpdocumentor-element-found-in">
<abbr class="phpdocumentor-element-found-in__file" title="app/Controllers/UserManagementController.php"><a href="files/app-controllers-usermanagementcontroller.html"><abbr title="app/Controllers/UserManagementController.php">UserManagementController.php</abbr></a></abbr>
:
<span class="phpdocumentor-element-found-in__line">109</span>
</aside>
<p class="phpdocumentor-summary">Zeigt die Seite zum Erstellen von Benutzern (Einzel/CSV).</p>
<code class="phpdocumentor-code phpdocumentor-signature ">
<span class="phpdocumentor-signature__visibility">public</span>
<span class="phpdocumentor-signature__name">create</span><span>(</span><span>)</span><span> : </span><span class="phpdocumentor-signature__response_type">array&lt;string, mixed&gt;</span></code>
<div class="phpdocumentor-label-line">
</div>
<section>
<h5 class="phpdocumentor-return-value__heading">Return values</h5>
<span class="phpdocumentor-signature__response_type">array&lt;string, mixed&gt;</span>
</section>
</article>
<article
class="phpdocumentor-element
@ -568,6 +614,7 @@
<li>
<ul class="phpdocumentor-list -clean">
<li class=""><a href="classes/App-Controllers-UserManagementController.html#method___construct">__construct()</a></li>
<li class=""><a href="classes/App-Controllers-UserManagementController.html#method_create">create()</a></li>
<li class=""><a href="classes/App-Controllers-UserManagementController.html#method_show">show()</a></li>
</ul>
</li>

View File

@ -80,6 +80,11 @@ Search.appendIndex(
"name": "show",
"summary": "Zeigt\u0020Benutzer\u002D\u0020und\u0020Gruppenliste\u0020an.",
"url": "classes/App-Controllers-UserManagementController.html#method_show"
}, {
"fqsen": "\\App\\Controllers\\UserManagementController\u003A\u003Acreate\u0028\u0029",
"name": "create",
"summary": "Zeigt\u0020die\u0020Seite\u0020zum\u0020Erstellen\u0020von\u0020Benutzern\u0020\u0028Einzel\/CSV\u0029.",
"url": "classes/App-Controllers-UserManagementController.html#method_create"
}, {
"fqsen": "\\App\\Controllers\\UserManagementController\u003A\u003A\u0024config",
"name": "config",

View File

@ -123,7 +123,7 @@
<tbody>
<tr>
<td class="phpdocumentor-cell">TODO</td>
<td class="phpdocumentor-cell">218</td>
<td class="phpdocumentor-cell">223</td>
<td class="phpdocumentor-cell">OS dynamisch per SNMP abfragen (OID 1.3.6.1.2.1.1.1.0) </td>
</tr>
</tbody>

181
public/api/create_user.php Normal file
View File

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
session_start();
// Load config
$config = require __DIR__ . '/../../config/config.php';
// Simple login check (same as index.php)
$sessionKey = $config['security']['session_key_user'] ?? 'admin_user';
if (!isset($_SESSION[$sessionKey])) {
header('Location: ../index.php?route=login');
exit;
}
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../index.php?route=createuser');
exit;
}
// Basic input validation
$sam = trim((string)($_POST['samaccountname'] ?? ''));
$display = trim((string)($_POST['displayname'] ?? ''));
$mail = trim((string)($_POST['mail'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
$ou = trim((string)($_POST['ou'] ?? ''));
$groups = trim((string)($_POST['groups'] ?? ''));
if ($sam === '' || $pass === '') {
$_SESSION['flash_error'] = 'Anmeldename und Passwort sind erforderlich.';
header('Location: ../index.php?route=createuser');
exit;
}
// Server-side password validation (same rules as CSV script)
$passwordHint = "Das Passwort muss mindestens 7 Zeichen lang sein, darf keine größeren Teile des Benutzernamens enthalten und muss Zeichen aus mindestens 3 von 4 Kategorien enthalten: Großbuchstaben, Kleinbuchstaben, Ziffern, Sonderzeichen.";
function validate_password_php(string $password, string $sam): array {
$errors = [];
if ($password === '' || mb_strlen($password) < 7) {
$errors[] = 'Passwort muss mindestens 7 Zeichen lang sein.';
}
$categories = 0;
if (preg_match('/[A-Z]/u', $password)) $categories++;
if (preg_match('/[a-z]/u', $password)) $categories++;
if (preg_match('/\d/', $password)) $categories++;
if (preg_match('/[^A-Za-z0-9]/u', $password)) $categories++;
if ($categories < 3) {
$errors[] = 'Passwort muss Zeichen aus mindestens 3 von 4 Kategorien enthalten.';
}
$samLower = mb_strtolower($sam);
$pwLower = mb_strtolower($password);
if ($samLower !== '' && mb_strpos($pwLower, $samLower) !== false) {
$errors[] = 'Passwort darf den Benutzernamen nicht enthalten.';
} else {
$minLen = 4;
if (mb_strlen($samLower) >= $minLen) {
$samLen = mb_strlen($samLower);
for ($len = $minLen; $len <= $samLen; $len++) {
for ($start = 0; $start <= $samLen - $len; $start++) {
$sub = mb_substr($samLower, $start, $len);
if (mb_strpos($pwLower, $sub) !== false) {
$errors[] = 'Passwort darf keine größeren Teile des Benutzernamens enthalten.';
break 2;
}
}
}
}
}
return $errors;
}
$pwErrors = validate_password_php($pass, $sam);
if (count($pwErrors) > 0) {
$_SESSION['flash_error'] = 'Ungültiges Passwort: ' . implode(' | ', $pwErrors) . "\n\nHinweis: $passwordHint";
header('Location: ../index.php?route=createuser');
exit;
}
if ($ou === '') {
$defaultOu = (string)($config['powershell']['default_ou'] ?? '');
if ($defaultOu !== '') {
$ou = $defaultOu;
}
}
// Build payload
$payload = [
'samaccountname' => $sam,
'displayname' => $display,
'mail' => $mail,
'password' => $pass,
'ou' => $ou,
'groups' => $groups,
'dry_run' => (bool)($config['powershell']['dry_run'] ?? false),
];
// Write payload to temp file
$tmpFile = tempnam(sys_get_temp_dir(), 'create_user_') . '.json';
file_put_contents($tmpFile, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
// Build PS script path
$scriptDir = $config['powershell']['script_dir'] ?? __DIR__ . '/../../scripts/powershell';
$script = $scriptDir . DIRECTORY_SEPARATOR . 'create_user.ps1';
$exe = $config['powershell']['exe'] ?? 'powershell';
$executionPolicy = $config['powershell']['execution_policy'] ?? 'Bypass';
$cmd = sprintf(
'%s -NoProfile -NonInteractive -ExecutionPolicy %s -File "%s" -InputFile "%s"',
$exe,
$executionPolicy,
$script,
$tmpFile
);
// Execute and capture output and exit code
$output = [];
$returnVar = null;
if (!file_exists($script)) {
$_SESSION['flash_error'] = 'PowerShell-Skript nicht gefunden: ' . $script;
@unlink($tmpFile);
header('Location: ../index.php?route=createuser');
exit;
}
// Try to locate the PowerShell executable
$exePathCheck = shell_exec(sprintf('where %s 2>NUL', escapeshellarg($exe)));
if ($exePathCheck === null) {
// 'where' returns null when command fails; continue anyways, exec will fail if not found
}
exec($cmd . ' 2>&1', $output, $returnVar);
$json = implode("\n", $output);
// Optional: write raw output into logs for debugging
$ts = date('Y-m-d H:i:s');
$ctx = [
'cmd' => $cmd,
'return_code' => $returnVar,
'output' => $json,
];
$line = sprintf(
"[%s] %-7s %s %s%s",
$ts,
'DEBUG',
'PowerShell CMD',
json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
PHP_EOL
);
@file_put_contents(__DIR__ . '/../logs/create_user_output.log', $line, FILE_APPEND | LOCK_EX);
@unlink($tmpFile);
// Try to parse JSON output
$result = null;
if ($json !== '') {
$decoded = json_decode($json, true);
if (is_array($decoded)) {
$result = $decoded;
}
}
if ($result === null) {
$_SESSION['flash_error'] = 'Unbekannter Fehler beim Ausführen des PowerShell-Skripts: ' . ($json ?: 'Keine Ausgabe');
header('Location: ../index.php?route=createuser');
exit;
}
if (!empty($result['success'])) {
$_SESSION['flash_success'] = $result['message'] ?? 'Benutzer erfolgreich erstellt.';
} else {
$_SESSION['flash_error'] = $result['message'] ?? 'Fehler beim Erstellen des Benutzers.';
}
header('Location: ../index.php?route=createuser');
exit;

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
session_start();
// Load config
$config = require __DIR__ . '/../../config/config.php';
// Simple login check (same as index.php)
$sessionKey = $config['security']['session_key_user'] ?? 'admin_user';
if (!isset($_SESSION[$sessionKey])) {
header('Location: ../index.php?route=login');
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../index.php?route=createuser');
exit;
}
$delimiter = (string)($_POST['csvdelimiter'] ?? ',');
$hasHeader = (string)($_POST['hasHeader'] ?? '1');
// Two sources: uploaded file or csvcontent textarea
$csvContent = '';
if (!empty($_FILES['csvfile']['tmp_name']) && is_uploaded_file($_FILES['csvfile']['tmp_name'])) {
$csvContent = file_get_contents($_FILES['csvfile']['tmp_name']);
} else {
$csvContent = (string)($_POST['csvcontent'] ?? '');
}
if (trim($csvContent) === '') {
$_SESSION['flash_error'] = 'CSV ist leer. Bitte Datei auswählen oder Inhalt einfügen.';
header('Location: ../index.php?route=createuser');
exit;
}
// Write CSV to temp file
$tmpFile = tempnam(sys_get_temp_dir(), 'create_users_') . '.csv';
file_put_contents($tmpFile, $csvContent);
// Build payload with options
$payload = [
'input_file' => $tmpFile,
'delimiter' => $delimiter,
'has_header' => (bool)((int)$hasHeader),
'dry_run' => (bool)($config['powershell']['dry_run'] ?? false),
'default_ou' => (string)($config['powershell']['default_ou'] ?? ''),
];
// Save options as JSON as the input to the PS script
$metaFile = tempnam(sys_get_temp_dir(), 'create_users_meta_') . '.json';
file_put_contents($metaFile, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$scriptDir = $config['powershell']['script_dir'] ?? __DIR__ . '/../../scripts/powershell';
$script = $scriptDir . DIRECTORY_SEPARATOR . 'create_users_csv.ps1';
$exe = $config['powershell']['exe'] ?? 'powershell';
$executionPolicy = $config['powershell']['execution_policy'] ?? 'Bypass';
$cmd = sprintf(
'%s -NoProfile -NonInteractive -ExecutionPolicy %s -File "%s" -InputFile "%s"',
$exe,
$executionPolicy,
$script,
$metaFile
);
$output = [];
$returnVar = null;
if (!file_exists($script)) {
$_SESSION['flash_error'] = 'PowerShell-Skript nicht gefunden: ' . $script;
@unlink($tmpFile);
@unlink($metaFile);
header('Location: ../index.php?route=createuser');
exit;
}
exec($cmd . ' 2>&1', $output, $returnVar);
$json = implode("\n", $output);
@unlink($tmpFile);
@unlink($metaFile);
// Optional: log the CSV script command and raw output to help debugging
$ts = date('Y-m-d H:i:s');
$ctx = [
'cmd' => $cmd,
'return_code' => $returnVar,
'output' => $json,
];
$line = sprintf(
"[%s] %-7s %s %s%s",
$ts,
'DEBUG',
'PowerShell CMD',
json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
PHP_EOL
);
@file_put_contents(__DIR__ . '/../logs/create_users_csv_output.log', $line, FILE_APPEND | LOCK_EX);
$result = null;
if ($json !== '') {
$decoded = json_decode($json, true);
if (is_array($decoded)) {
$result = $decoded;
}
}
if ($result === null) {
$_SESSION['flash_error'] = 'Unbekannter Fehler beim Ausführen des PowerShell-Skripts: ' . ($json ?: 'Keine Ausgabe');
header('Location: ../index.php?route=createuser');
exit;
}
if (!empty($result['success'])) {
$_SESSION['flash_success'] = $result['message'] ?? 'CSV verarbeitet.';
if (!empty($result['details'])) {
$_SESSION['csv_details'] = $result['details'];
}
} else {
$_SESSION['flash_error'] = $result['message'] ?? 'Fehler beim Verarbeiten der CSV.';
}
header('Location: ../index.php?route=createuser');
exit;

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
session_start();
// Load config
$config = require __DIR__ . '/../../config/config.php';
// Simple login check (same as index.php)
$sessionKey = $config['security']['session_key_user'] ?? 'admin_user';
if (!isset($_SESSION[$sessionKey])) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Not authenticated']);
exit;
}
$scriptDir = $config['powershell']['script_dir'] ?? __DIR__ . '/../../scripts/powershell';
$script = $scriptDir . DIRECTORY_SEPARATOR . 'check_environment.ps1';
$exe = $config['powershell']['exe'] ?? 'powershell';
$executionPolicy = $config['powershell']['execution_policy'] ?? 'Bypass';
if (!file_exists($script)) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Script not found: ' . $script]);
exit;
}
$cmd = sprintf('%s -NoProfile -NonInteractive -ExecutionPolicy %s -File "%s"', $exe, $executionPolicy, $script);
$output = [];
$returnVar = null;
exec($cmd . ' 2>&1', $output, $returnVar);
$json = implode("\n", $output);
// Attempt to parse JSON
$decoded = json_decode($json, true);
if ($decoded === null) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Invalid JSON output', 'raw' => $json]);
exit;
}
header('Content-Type: application/json');
echo json_encode($decoded);
exit;

View File

@ -11,27 +11,27 @@
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root {
--blue: #4e73df;
--indigo: #6610f2;
--purple: #6f42c1;
--pink: #e83e8c;
--red: #e74a3b;
--orange: #fd7e14;
--yellow: #f6c23e;
--green: #1cc88a;
--teal: #20c9a6;
--cyan: #36b9cc;
--blue: #3b82f6;
--indigo: #6366f1;
--purple: #8b5cf6;
--pink: #ec4899;
--red: #ef4444;
--orange: #f97316;
--yellow: #f59e0b;
--green: #10b981;
--teal: #14b8a6;
--cyan: #06b6d4;
--white: #fff;
--gray: #858796;
--gray-dark: #5a5c69;
--primary: #4e73df;
--secondary: #858796;
--success: #1cc88a;
--info: #36b9cc;
--warning: #f6c23e;
--danger: #e74a3b;
--light: #f8f9fc;
--dark: #5a5c69;
--gray: #9ca3af;
--gray-dark: #374151;
--primary: #3b82f6;
--secondary: #6b7280;
--success: #10b981;
--info: #06b6d4;
--warning: #f59e0b;
--danger: #ef4444;
--light: #1f2937;
--dark: #111827;
--breakpoint-xs: 0;
--breakpoint-sm: 576px;
--breakpoint-md: 768px;
@ -64,9 +64,9 @@ body {
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #858796;
color: #ffffff;
text-align: left;
background-color: #fff;
background-color: #0f172a;
}
[tabindex="-1"]:focus:not(:focus-visible) {
@ -159,13 +159,13 @@ sup {
}
a {
color: #4e73df;
color: #60a5fa;
text-decoration: none;
background-color: transparent;
}
a:hover {
color: #224abe;
color: #3b82f6;
text-decoration: underline;
}
@ -364,6 +364,7 @@ h1, h2, h3, h4, h5, h6,
margin-bottom: 0.5rem;
font-weight: 400;
line-height: 1.2;
color: #f8fafc;
}
h1, .h1 {
@ -1485,23 +1486,23 @@ pre code {
.table {
width: 100%;
margin-bottom: 1rem;
color: #858796;
color: #ffffff;
}
.table th,
.table td {
padding: 0.75rem;
vertical-align: top;
border-top: 1px solid #e3e6f0;
border-top: 1px solid #334155;
}
.table thead th {
vertical-align: bottom;
border-bottom: 2px solid #e3e6f0;
border-bottom: 2px solid #334155;
}
.table tbody + tbody {
border-top: 2px solid #e3e6f0;
border-top: 2px solid #334155;
}
.table-sm th,
@ -1510,12 +1511,12 @@ pre code {
}
.table-bordered {
border: 1px solid #e3e6f0;
border: 1px solid #334155;
}
.table-bordered th,
.table-bordered td {
border: 1px solid #e3e6f0;
border: 1px solid #334155;
}
.table-bordered thead th,
@ -1531,12 +1532,12 @@ pre code {
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.05);
background-color: rgba(255, 255, 255, 0.05);
}
.table-hover tbody tr:hover {
color: #858796;
background-color: rgba(0, 0, 0, 0.075);
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
}
.table-primary,
@ -1737,9 +1738,9 @@ pre code {
}
.table .thead-light th {
color: #6e707e;
background-color: #eaecf4;
border-color: #e3e6f0;
color: #e2e8f0;
background-color: #1e293b;
border-color: #334155;
}
.table-dark {
@ -1833,10 +1834,10 @@ pre code {
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #6e707e;
background-color: #fff;
color: #ffffff;
background-color: #1e293b;
background-clip: padding-box;
border: 1px solid #d1d3e2;
border: 1px solid #334155;
border-radius: 0.35rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
@ -1858,15 +1859,15 @@ pre code {
}
.form-control:focus {
color: #6e707e;
background-color: #fff;
border-color: #bac8f3;
color: #ffffff;
background-color: #1e293b;
border-color: #3b82f6;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
.form-control::-webkit-input-placeholder {
color: #858796;
color: #64748b;
opacity: 1;
}
@ -3024,12 +3025,12 @@ input[type="button"].btn-block {
padding: 0.5rem 0;
margin: 0.125rem 0 0;
font-size: 0.85rem;
color: #858796;
color: #f8fafc;
text-align: left;
list-style: none;
background-color: #fff;
background-color: #1e293b;
background-clip: padding-box;
border: 1px solid #e3e6f0;
border: 1px solid #334155;
border-radius: 0.35rem;
}
@ -3182,7 +3183,7 @@ input[type="button"].btn-block {
height: 0;
margin: 0.5rem 0;
overflow: hidden;
border-top: 1px solid #eaecf4;
border-top: 1px solid #334155;
}
.dropdown-item {
@ -3191,7 +3192,7 @@ input[type="button"].btn-block {
padding: 0.25rem 1.5rem;
clear: both;
font-weight: 400;
color: #3a3b45;
color: #e2e8f0;
text-align: inherit;
white-space: nowrap;
background-color: transparent;
@ -3199,19 +3200,19 @@ input[type="button"].btn-block {
}
.dropdown-item:hover, .dropdown-item:focus {
color: #2e2f37;
color: #ffffff;
text-decoration: none;
background-color: #eaecf4;
background-color: #334155;
}
.dropdown-item.active, .dropdown-item:active {
color: #fff;
text-decoration: none;
background-color: #4e73df;
background-color: #3b82f6;
}
.dropdown-item.disabled, .dropdown-item:disabled {
color: #b7b9cc;
color: #94a3b8;
pointer-events: none;
background-color: transparent;
}
@ -4489,9 +4490,9 @@ input[type="button"].btn-block {
flex-direction: column;
min-width: 0;
word-wrap: break-word;
background-color: #fff;
background-color: #1e293b;
background-clip: border-box;
border: 1px solid #e3e6f0;
border: 1px solid #334155;
border-radius: 0.35rem;
}
@ -4552,8 +4553,8 @@ input[type="button"].btn-block {
.card-header {
padding: 0.75rem 1.25rem;
margin-bottom: 0;
background-color: #f8f9fc;
border-bottom: 1px solid #e3e6f0;
background-color: #0f172a;
border-bottom: 1px solid #334155;
}
.card-header:first-child {
@ -4562,8 +4563,8 @@ input[type="button"].btn-block {
.card-footer {
padding: 0.75rem 1.25rem;
background-color: #f8f9fc;
border-top: 1px solid #e3e6f0;
background-color: #0f172a;
border-top: 1px solid #334155;
}
.card-footer:last-child {
@ -6491,7 +6492,7 @@ button.bg-dark:focus {
}
.bg-white {
background-color: #fff !important;
background-color: #1e293b !important;
}
.bg-transparent {
@ -6499,15 +6500,15 @@ button.bg-dark:focus {
}
.border {
border: 1px solid #e3e6f0 !important;
border: 1px solid #334155 !important;
}
.border-top {
border-top: 1px solid #e3e6f0 !important;
border-top: 1px solid #334155 !important;
}
.border-right {
border-right: 1px solid #e3e6f0 !important;
border-right: 1px solid #334155 !important;
}
.border-bottom {
@ -9660,7 +9661,7 @@ a.text-dark:hover, a.text-dark:focus {
}
.text-muted {
color: #858796 !important;
color: #cbd5e1 !important;
}
.text-black-50 {
@ -9797,7 +9798,7 @@ a:focus {
}
#wrapper #content-wrapper {
background-color: #f8f9fc;
background-color: #0f172a;
width: 100%;
overflow-x: hidden;
}
@ -9900,8 +9901,8 @@ a:focus {
}
.bg-gradient-primary {
background-color: #4e73df;
background-image: linear-gradient(180deg, #4e73df 10%, #224abe 100%);
background-color: #1e3a8a;
background-image: linear-gradient(180deg, #1e40af 0%, #1e3a8a 50%, #172554 100%);
background-size: cover;
}
@ -9996,39 +9997,39 @@ a:focus {
}
.text-gray-100 {
color: #f8f9fc !important;
color: #ffffff !important;
}
.text-gray-200 {
color: #eaecf4 !important;
color: #f8fafc !important;
}
.text-gray-300 {
color: #dddfeb !important;
color: #f1f5f9 !important;
}
.text-gray-400 {
color: #d1d3e2 !important;
color: #e2e8f0 !important;
}
.text-gray-500 {
color: #b7b9cc !important;
color: #cbd5e1 !important;
}
.text-gray-600 {
color: #858796 !important;
color: #cbd5e1 !important;
}
.text-gray-700 {
color: #6e707e !important;
color: #e2e8f0 !important;
}
.text-gray-800 {
color: #5a5c69 !important;
color: #f1f5f9 !important;
}
.text-gray-900 {
color: #3a3b45 !important;
color: #ffffff !important;
}
.icon-circle {
@ -10169,6 +10170,8 @@ a:focus {
.topbar {
height: 4.375rem;
background-color: #1e293b;
border-bottom: 1px solid #334155;
}
.topbar #sidebarToggleTop {
@ -10388,7 +10391,7 @@ a:focus {
padding: 0.5rem 1rem;
margin: 0 0.5rem;
display: block;
color: #3a3b45;
color: #cbd5e1;
text-decoration: none;
border-radius: 0.35rem;
white-space: nowrap;
@ -10396,17 +10399,17 @@ a:focus {
.sidebar .nav-item .collapse .collapse-inner .collapse-item:hover,
.sidebar .nav-item .collapsing .collapse-inner .collapse-item:hover {
background-color: #eaecf4;
background-color: #334155;
}
.sidebar .nav-item .collapse .collapse-inner .collapse-item:active,
.sidebar .nav-item .collapsing .collapse-inner .collapse-item:active {
background-color: #dddfeb;
background-color: #475569;
}
.sidebar .nav-item .collapse .collapse-inner .collapse-item.active,
.sidebar .nav-item .collapsing .collapse-inner .collapse-item.active {
color: #4e73df;
color: #3b82f6;
font-weight: 700;
}
@ -11269,11 +11272,13 @@ form.user .btn-user {
footer.sticky-footer {
padding: 2rem 0;
flex-shrink: 0;
background-color: #0f172a;
}
footer.sticky-footer .copyright {
line-height: 1;
font-size: 0.8rem;
color: #94a3b8;
}
body.sidebar-toggled footer.sticky-footer {

View File

@ -74,6 +74,8 @@ use App\Controllers\AuthController;
use App\Controllers\DashboardController;
use App\Controllers\UserManagementController;
use App\Services\Logging\LoggingService;
use App\Controllers\LogViewerController;
// Globalen Logger initialisieren, damit auch Fehler außerhalb der Controller
// (z. B. in index.php selbst) sauber protokolliert werden.
@ -199,6 +201,8 @@ function handleResult(?array $result): void
$authController = new AuthController($config);
$dashboardController = new DashboardController($config);
$userManagementController = new UserManagementController($config);
$logViewerController = new LogViewerController($config);
// Route aus dem Query-Parameter lesen. Standardroute ist "login",
// sodass nicht angemeldete Benutzer automatisch auf die Login-Seite geführt werden.
@ -240,6 +244,18 @@ switch ($route) {
handleResult($result);
break;
case 'createuser':
requireLogin($config);
$result = $userManagementController->create();
handleResult($result);
break;
case 'logs':
requireLogin($config);
$result = $logViewerController->show();
handleResult($result);
break;
default:
http_response_code(404);
echo 'Route nicht gefunden.';

496
public/views/createuser.php Normal file
View File

@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
/**
* View-Template zur Erstellung von Active-Directory-Benutzern.
*
* Funktionen:
* - Formular zum Anlegen eines einzelnen Benutzers (sAMAccountName, Anzeigename, E-Mail, Passwort, OU, Gruppen).
* - Formular zum Hochladen einer CSV-Datei zum Anlegen mehrerer Benutzer.
* - Vorschau-Textbox, in der die CSV-Datei angezeigt und bearbeitet werden kann, bevor sie abgesendet wird.
* - Gibt optionalen Erfolg / Fehler aus.
*
* Erwartete View-Daten:
* - string|null $error Fehlermeldung
* - string|null $success Erfolgsmeldung
*/
/**
* @var string|null $error
* @var string|null $success
*/
?>
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Benutzer erstellen</h1>
</div>
<?php if (!empty($error)): ?>
<div class="alert alert-danger" role="alert">
<?php echo htmlspecialchars((string)$error, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</div>
<?php endif; ?>
<?php if (!empty($success)): ?>
<div class="alert alert-success" role="alert">
<?php echo htmlspecialchars((string)$success, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</div>
<?php endif; ?>
<?php if (!empty($powershellDryRun) && $powershellDryRun === true): ?>
<div class="alert alert-warning" role="alert">
Die Anwendung ist im <strong>Dry-Run</strong>-Modus konfiguriert; PowerShell-Befehle werden nicht ausgeführt.
</div>
<?php endif; ?>
<p class="mb-4">Hier können Sie einzelne Active-Directory-Benutzer anlegen oder eine CSV-Datei hochladen, um mehrere Benutzer gleichzeitig zu erstellen. Sie können die CSV in der Vorschau bearbeiten bevor Sie die Erstellung auslösen.</p>
<div class="row">
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Einzelner Benutzer</h6>
</div>
<div class="card-body">
<form method="post" action="../api/create_user.php">
<div class="form-group">
<label for="samaccountname">Anmeldename (sAMAccountName)</label>
<input type="text" class="form-control" id="samaccountname" name="samaccountname" required>
</div>
<div class="form-group">
<label for="displayname">Anzeigename</label>
<input type="text" class="form-control" id="displayname" name="displayname">
</div>
<div class="form-group">
<label for="mail">E-Mail</label>
<input type="email" class="form-control" id="mail" name="mail">
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" class="form-control" id="password" name="password" required>
<small class="form-text text-muted">Das Passwort muss mindestens 7 Zeichen lang sein, darf keine größeren Teile des Benutzernamens enthalten und muss Zeichen aus mindestens 3 von 4 Kategorien enthalten: Großbuchstaben, Kleinbuchstaben, Ziffern, Sonderzeichen.</small>
</div>
<div class="form-group">
<label for="groups">Gruppen (kommagetrennt, optional)</label>
<input type="text" class="form-control" id="groups" name="groups" placeholder="Domain Users,IT-Staff">
</div>
<button type="submit" class="btn btn-primary">Benutzer erstellen</button>
</form>
<script>
(function () {
const form = document.querySelector('form[action="/api/create_user.php"]');
if (!form) return;
function validatePassword(password, sam) {
const errors = [];
if (!password || password.length < 7) errors.push('Passwort muss mindestens 7 Zeichen lang sein.');
let categories = 0;
if (/[A-Z]/.test(password)) categories++;
if (/[a-z]/.test(password)) categories++;
if (/\d/.test(password)) categories++;
if (/[^A-Za-z0-9]/.test(password)) categories++;
if (categories < 3) errors.push('Passwort muss Zeichen aus mindestens 3 von 4 Kategorien enthalten.');
if (sam) {
const pwLower = password.toLowerCase();
const samLower = sam.toLowerCase();
if (pwLower.includes(samLower)) errors.push('Passwort darf den Benutzernamen nicht enthalten.');
else {
const minLen = 4;
if (samLower.length >= minLen) {
outer: for (let len = minLen; len <= samLower.length; len++) {
for (let s = 0; s <= samLower.length - len; s++) {
const sub = samLower.substr(s, len);
if (pwLower.includes(sub)) { errors.push('Passwort darf keine größeren Teile des Benutzernamens enthalten.'); break outer; }
}
}
}
}
}
return errors;
}
form.addEventListener('submit', function (e) {
const sam = document.getElementById('samaccountname').value.trim();
const password = document.getElementById('password').value;
const errs = validatePassword(password, sam);
if (errs.length > 0) {
e.preventDefault();
alert('Passwort ungültig:\n' + errs.join('\n'));
return false;
}
return true;
});
})();
</script>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Mehrere Benutzer via CSV</h6>
</div>
<div class="card-body">
<p class="small text-muted">Die CSV-Datei sollte eine Kopfzeile mit folgenden Spalten enthalten: <code>samaccountname,displayname,mail,password,groups</code>. Gruppen können komma-getrennt sein. Nach dem Hochladen erscheint der Inhalt in der Vorschau, dort kann er vor dem Absenden editiert werden.</p>
<form id="csvUploadForm" method="post" action="../api/create_users_csv.php" enctype="multipart/form-data">
<div class="form-group">
<label for="csvfile">CSV-Datei</label>
<input type="file" class="form-control-file" id="csvfile" name="csvfile" accept=".csv">
</div>
<div class="form-group">
<label for="csvdelimiter">Trennzeichen</label>
<select class="form-control" id="csvdelimiter" name="csvdelimiter">
<option value="," selected>Komma (,)</option>
<option value=";">Semikolon (;)</option>
</select>
</div>
<div class="form-group">
<label for="hasHeader">Kopfzeile vorhanden?</label>
<select class="form-control" id="hasHeader" name="hasHeader">
<option value="1" selected>Ja</option>
<option value="0">Nein</option>
</select>
</div>
<div class="form-group">
<label for="csvpreview">CSV-Vorschau (editierbar)</label>
<textarea id="csvpreview" name="csvcontent" rows="12" class="form-control" placeholder="CSV-Inhalt wird hier angezeigt, nachdem Sie eine Datei ausgewählt haben. Sie können den Text bearbeiten, bevor Sie ihn absenden."></textarea>
</div>
<!-- CSV preview table and validation info -->
<div id="csvPreviewArea" style="display:none; margin-top:1rem;">
<div id="csvPreviewInfo" class="small text-muted mb-2">CSV-Vorschau geladen. Passwörter werden geprüft.</div>
<div style="overflow-x:auto; max-height:260px;">
<table id="csvPreviewTable" class="table table-sm table-bordered" style="width:100%; border-collapse:collapse;"></table>
</div>
</div>
<style>
/* Make the CSV action buttons equal width and aligned */
.csv-btn-row { display:flex; gap:0.5rem; align-items:center; }
.csv-btn-row .btn-equal { flex: 0 0 150px; width: 150px; display:inline-flex; justify-content:center; align-items:center; box-sizing:border-box; white-space:nowrap; }
.csv-preview-hint { margin-top:0.6rem; font-size:0.9rem; color:#6c757d; }
</style>
<div class="form-group">
<div class="csv-btn-row">
<button type="button" id="loadPreviewBtn" class="btn btn-secondary btn-equal" title="Hinweis: Beim Laden der Vorschau werden Änderungen in der Vorschau verworfen und mit der Originaldatei ersetzt.">In Vorschau laden</button>
<button type="submit" class="btn btn-primary btn-equal">CSV verarbeiten</button>
<button type="button" id="clearPreviewBtn" class="btn btn-light btn-equal">Vorschau löschen</button>
</div>
<div class="csv-preview-hint">Beim Laden werden Änderungen in der Vorschau verworfen und die Originaldatei neu eingelesen.</div>
</div>
</form>
<small class="form-text text-muted mt-2">Tipp: Wenn Sie die CSV im Textfeld bearbeiten, wird der bearbeitete Text an den Server gesendet.</small>
</div>
</div>
</div>
</div>
<?php if (!empty($csvDetails) && is_array($csvDetails)): ?>
<div class="row mt-4">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-secondary">CSV Verarbeitungsergebnisse</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Anmeldename</th>
<th>Status</th>
<th>Hinweis</th>
</tr>
</thead>
<tbody>
<?php foreach ($csvDetails as $detail): ?>
<tr>
<td><?php echo htmlspecialchars((string)($detail['sam'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?></td>
<td><?php echo (!empty($detail['success'])) ? 'OK' : 'FEHLER'; ?></td>
<td><?php echo htmlspecialchars((string)($detail['message'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-secondary">Hinweise</h6>
</div>
<div class="card-body">
<ul>
<li>Die tatsächliche Erstellung von AD-Benutzern wird serverseitig durchgeführt. Diese View sendet Daten an die Endpunkte <code>/api/create_user.php</code> und <code>/api/create_users_csv.php</code>.</li>
<li>Stellen Sie sicher, dass der Webserver die nötigen Rechte hat und die LDAP/AD-Verbindung korrekt konfiguriert ist.</li>
<li>Für Sicherheit: prüfen Sie bitte CSRF-Schutz und Validierung auf der Serverseite.</li>
</ul>
</div>
</div>
</div>
</div>
<script>
(function () {
const fileInput = document.getElementById('csvfile');
const preview = document.getElementById('csvpreview');
const loadBtn = document.getElementById('loadPreviewBtn');
const clearBtn = document.getElementById('clearPreviewBtn');
const form = document.getElementById('csvUploadForm');
function readFileToPreview(file) {
const reader = new FileReader();
reader.onload = function (e) {
preview.value = e.target.result || '';
};
reader.onerror = function () {
alert('Fehler beim Lesen der Datei.');
};
reader.readAsText(file, 'utf-8');
}
if (loadBtn) {
loadBtn.addEventListener('click', function () {
const file = fileInput.files && fileInput.files[0];
if (!file) {
// Wenn keine Datei ausgewählt ist, laden wir nichts, behalten aber vorhandenen Text.
alert('Bitte wählen Sie zuerst eine CSV-Datei aus oder fügen Sie CSV-Text direkt in das Feld ein.');
return;
}
readFileToPreview(file);
});
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
preview.value = '';
fileInput.value = '';
});
}
// Falls der Benutzer direkt die Datei auswählt, füllen wir die Vorschau automatisch.
if (fileInput) {
fileInput.addEventListener('change', function () {
const file = fileInput.files && fileInput.files[0];
if (file) {
readFileToPreview(file);
}
});
}
// CSV utilities and validation for passwords in preview
function parseCsvText(text, delimiter) {
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l !== '');
if (lines.length === 0) return { headers: [], rows: [] };
const headers = lines[0].split(delimiter).map(h => h.trim().replace(/^"|"$/g, ''));
const rows = lines.slice(1).map(l => l.split(delimiter).map(c => c.trim().replace(/^"|"$/g, '')));
return { headers, rows };
}
function validatePasswordJS(password, sam) {
const errors = [];
if (!password || password.length < 7) {
errors.push('Passwort muss mindestens 7 Zeichen lang sein.');
}
let categories = 0;
if (/[A-Z]/.test(password)) categories++;
if (/[a-z]/.test(password)) categories++;
if (/\d/.test(password)) categories++;
if (/[^A-Za-z0-9]/.test(password)) categories++;
if (categories < 3) {
errors.push('Passwort muss Zeichen aus mindestens 3 von 4 Kategorien enthalten.');
}
if (sam) {
const pwLower = password.toLowerCase();
const samLower = sam.toLowerCase();
if (pwLower.includes(samLower)) {
errors.push('Passwort darf den Benutzernamen nicht enthalten.');
} else {
const minLen = 4;
if (samLower.length >= minLen) {
outer: for (let len = minLen; len <= samLower.length; len++) {
for (let s = 0; s <= samLower.length - len; s++) {
const sub = samLower.substr(s, len);
if (pwLower.includes(sub)) { errors.push('Passwort darf keine größeren Teile des Benutzernamens enthalten.'); break outer; }
}
}
}
}
}
return errors;
}
function renderCsvPreview(text, delimiter) {
const parsed = parseCsvText(text, delimiter);
const headers = parsed.headers;
const rows = parsed.rows;
const previewArea = document.getElementById('csvPreviewArea');
const previewInfo = document.getElementById('csvPreviewInfo');
const table = document.getElementById('csvPreviewTable');
table.innerHTML = '';
if (headers.length === 0) {
previewArea.style.display = 'none';
return { invalidCount: 0 };
}
// build header
const thead = document.createElement('thead');
const trh = document.createElement('tr');
headers.forEach(h => { const th = document.createElement('th'); th.textContent = h; trh.appendChild(th); });
thead.appendChild(trh);
table.appendChild(thead);
// find indexes
const pwdIdx = headers.findIndex(h => /pass(word)?/i.test(h));
const samIdx = headers.findIndex(h => /(sam(accountname)?)|samaccountname/i.test(h));
const tbody = document.createElement('tbody');
let invalidCount = 0;
rows.forEach((rowArr, rowIndex) => {
const tr = document.createElement('tr');
rowArr.forEach((cell, colIndex) => {
const td = document.createElement('td');
const input = document.createElement('input');
input.type = 'text';
input.value = cell;
input.className = 'form-control form-control-sm';
input.addEventListener('input', function () {
// re-validate this row when edited
const currentPwd = (pwdIdx >= 0) ? tr.querySelectorAll('input')[pwdIdx].value : '';
const currentSam = (samIdx >= 0) ? tr.querySelectorAll('input')[samIdx].value : '';
const errs = validatePasswordJS(currentPwd, currentSam);
if (errs.length > 0) {
tr.classList.add('table-danger');
} else {
tr.classList.remove('table-danger');
}
});
td.appendChild(input);
tr.appendChild(td);
});
// validate password for this row if applicable
if (pwdIdx >= 0) {
const pwd = rowArr[pwdIdx] || '';
const sam = (samIdx >= 0) ? (rowArr[samIdx] || '') : '';
const errs = validatePasswordJS(pwd, sam);
if (errs.length > 0) {
tr.classList.add('table-danger');
invalidCount++;
}
}
tbody.appendChild(tr);
});
table.appendChild(tbody);
previewArea.style.display = 'block';
previewInfo.textContent = (invalidCount > 0)
? `${invalidCount} Zeile(n) haben ungültige Passwörter. Bitte korrigieren Sie diese in der Tabelle bevor Sie die CSV verarbeiten.`
: 'CSV-Vorschau geladen. Alle Passwörter entsprechen den Anforderungen.';
return { invalidCount };
}
if (loadBtn) {
loadBtn.addEventListener('click', function () {
const file = fileInput.files && fileInput.files[0];
const delim = document.getElementById('csvdelimiter').value || ',';
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
preview.value = e.target.result || '';
renderCsvPreview(preview.value, delim);
};
reader.readAsText(file, 'utf-8');
return;
}
if (preview.value.trim() === '') {
alert('Bitte wählen Sie zuerst eine CSV-Datei aus oder fügen Sie CSV-Text direkt in das Feld ein.');
return;
}
renderCsvPreview(preview.value, delim);
});
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
preview.value = '';
fileInput.value = '';
document.getElementById('csvPreviewArea').style.display = 'none';
});
}
if (fileInput) {
fileInput.addEventListener('change', function () {
// auto-load content into preview textarea (but do not auto-validate until user clicks 'In Vorschau laden')
const file = fileInput.files && fileInput.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
preview.value = e.target.result || '';
};
reader.readAsText(file, 'utf-8');
}
});
}
// Intercept submit: read file if needed, validate preview passwords, then submit
function handleCsvSubmit(e) {
e.preventDefault();
const delim = document.getElementById('csvdelimiter').value || ',';
const file = fileInput.files && fileInput.files[0];
const proceedWithText = function (text) {
const res = renderCsvPreview(text, delim);
if (res.invalidCount > 0) {
alert('Import abgebrochen: Es gibt ungültige Passwörter in der CSV-Vorschau. Bitte korrigieren Sie diese zuerst.');
return;
}
// ensure preview textarea contains the text we'll submit
preview.value = text;
// Remove handler to avoid re-validation loop and submit the form
form.removeEventListener('submit', handleCsvSubmit);
form.submit();
};
if (preview.value.trim() === '') {
if (file) {
const reader = new FileReader();
reader.onload = function (ev) {
const text = ev.target.result || '';
proceedWithText(text);
};
reader.onerror = function () {
alert('Fehler beim Lesen der Datei. Bitte versuchen Sie es erneut.');
};
reader.readAsText(file, 'utf-8');
return;
}
alert('Die CSV-Vorschau ist leer. Bitte wählen Sie eine Datei oder fügen Sie CSV-Inhalt ein.');
return;
}
proceedWithText(preview.value);
}
form.addEventListener('submit', handleCsvSubmit);
})();
</script>
<?php // Ende der View ?>

View File

@ -12,6 +12,22 @@ declare(strict_types=1);
/** @var array<string, mixed> $serverStatus */
?>
<style>
/* Custom Farben für Dashboard-Karten */
.border-left-purple {
border-left: 0.25rem solid #6f42c1 !important;
}
.text-purple {
color: #6f42c1 !important;
}
.border-left-amber {
border-left: 0.25rem solid #ff9800 !important;
}
.text-amber {
color: #ff9800 !important;
}
</style>
<!-- Content Row -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Server-Dashboard</h1>
@ -106,11 +122,11 @@ declare(strict_types=1);
<div class="row">
<!-- Disk C: / Root -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-secondary shadow h-100 py-2">
<div class="card border-left-purple shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-secondary text-uppercase mb-1">
<div class="text-xs font-weight-bold text-purple text-uppercase mb-1">
Datenträger C: / Root
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
@ -127,11 +143,11 @@ declare(strict_types=1);
<!-- Uptime -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-dark shadow h-100 py-2">
<div class="card border-left-amber shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-dark text-uppercase mb-1">
<div class="text-xs font-weight-bold text-amber text-uppercase mb-1">
System Uptime
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">

165
public/views/logs.php Normal file
View File

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
/**
* Log Viewer (Ansicht).
*
* Erwartete Variablen:
* @var array<int, array{name:string, size:int, mtime:int}> $files
* @var string $selectedFile
* @var array{name:string, size:int, mtime:int}|null $fileMeta
* @var array<int, array{ts:string|null, level:string|null, message:string, context:array<string,mixed>|null, raw:string}> $entries
* @var string $filterLevel
* @var string $searchQuery
* @var int $lines
* @var string|null $error
*/
?>
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Log Viewer</h1>
</div>
<?php if (!empty($error)) : ?>
<div class="alert alert-danger" role="alert">
<?php echo htmlspecialchars((string)$error, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</div>
<?php endif; ?>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Filter</h6>
</div>
<div class="card-body">
<form method="get" action="../index.php">
<input type="hidden" name="route" value="logs">
<div class="form-row">
<div class="form-group col-md-4">
<label for="file">Log-Datei</label>
<select class="form-control" id="file" name="file">
<?php foreach (($files ?? []) as $f) : ?>
<?php $name = (string)($f['name'] ?? ''); ?>
<option value="<?php echo htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>"
<?php echo ($name === (string)$selectedFile) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group col-md-2">
<label for="level">Level</label>
<select class="form-control" id="level" name="level">
<?php
$levels = ['', 'ERROR', 'WARNING', 'INFO', 'DEBUG'];
foreach ($levels as $lvl) :
$label = ($lvl === '') ? 'Alle' : $lvl;
?>
<option value="<?php echo htmlspecialchars($lvl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>"
<?php echo ((string)$filterLevel === (string)$lvl) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group col-md-3">
<label for="q">Suche</label>
<input type="text" class="form-control" id="q" name="q"
value="<?php echo htmlspecialchars((string)$searchQuery, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>"
placeholder="Text in message/context">
</div>
<div class="form-group col-md-2">
<label for="lines">Zeilen</label>
<input type="number" class="form-control" id="lines" name="lines"
value="<?php echo (int)$lines; ?>" min="1" max="2000">
</div>
<div class="form-group col-md-1 d-flex align-items-end">
<button type="submit" class="btn btn-primary btn-block">
Anzeigen
</button>
</div>
</div>
<?php if (is_array($fileMeta)) : ?>
<div class="small text-gray-600">
Datei: <strong><?php echo htmlspecialchars((string)$fileMeta['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?></strong>
· Größe: <?php echo number_format((int)$fileMeta['size'], 0, ',', '.'); ?> Bytes
· Stand: <?php echo ($fileMeta['mtime'] ?? 0) > 0 ? date('d.m.Y H:i:s', (int)$fileMeta['mtime']) : 'n/a'; ?>
</div>
<?php endif; ?>
</form>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">Einträge (neueste unten)</h6>
<a class="btn btn-sm btn-outline-secondary"
href="../index.php?route=logs&amp;file=<?php echo urlencode((string)$selectedFile); ?>&amp;level=<?php echo urlencode((string)$filterLevel); ?>&amp;q=<?php echo urlencode((string)$searchQuery); ?>&amp;lines=<?php echo (int)$lines; ?>">
Refresh
</a>
</div>
<div class="card-body">
<?php if (empty($entries)) : ?>
<div class="text-gray-600">Keine Einträge gefunden.</div>
<?php else : ?>
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead>
<tr>
<th style="width: 170px;">Zeit</th>
<th style="width: 110px;">Level</th>
<th>Message</th>
<th style="width: 35%;">Context</th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $e) : ?>
<?php
$lvl = strtoupper((string)($e['level'] ?? ''));
$badge = 'badge-secondary';
if ($lvl === 'ERROR') { $badge = 'badge-danger'; }
if ($lvl === 'WARNING') { $badge = 'badge-warning'; }
if ($lvl === 'INFO') { $badge = 'badge-info'; }
if ($lvl === 'DEBUG') { $badge = 'badge-light'; }
$ctx = $e['context'] ?? null;
$ctxPretty = '';
if (is_array($ctx)) {
$ctxPretty = (string)json_encode($ctx, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
?>
<tr>
<td><?php echo htmlspecialchars((string)($e['ts'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?></td>
<td>
<span class="badge <?php echo $badge; ?>">
<?php echo htmlspecialchars($lvl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</span>
</td>
<td><?php echo htmlspecialchars((string)($e['message'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?></td>
<td>
<?php if ($ctxPretty !== '') : ?>
<pre class="mb-0 text-white" style="white-space: pre-wrap;">
<?php echo htmlspecialchars($ctxPretty, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
</pre>
<?php else : ?>
<span class="text-gray-600">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="small text-gray-600">
Hinweis: Dieser Viewer lädt bewusst nur die letzten Zeilen (Tail), damit große Logfiles die Oberfläche nicht killen.
</div>
<?php endif; ?>
</div>
</div>

View File

@ -19,12 +19,12 @@
<title>AD Admin Tool<?= isset($pageTitle) ? ' ' . htmlspecialchars($pageTitle, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') : '' ?></title>
<!-- Custom fonts for this template-->
<link href="../../vendor/fontawesome-free/css/all.min.css" rel="stylesheet" type="text/css">
<link href="/vendor/fontawesome-free/css/all.min.css" rel="stylesheet" type="text/css">
<!-- Google Fonts oder lokal, je nach Setup -->
<link href="../../css/sb-admin-2.min.css" rel="stylesheet">
<link href="/css/sb-admin-2.css" rel="stylesheet">
<!-- DataTables CSS (falls benötigt) -->
<link href="../../vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
<link href="/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
</head>

View File

@ -21,23 +21,23 @@
</a>
<!-- Bootstrap core JavaScript-->
<script src="../../vendor/jquery/jquery.min.js"></script>
<script src="../../vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/vendor/jquery/jquery.min.js"></script>
<script src="/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- Core plugin JavaScript-->
<script src="../../vendor/jquery-easing/jquery.easing.min.js"></script>
<script src="/vendor/jquery-easing/jquery.easing.min.js"></script>
<!-- Custom scripts for all pages-->
<script src="../../js/sb-admin-2.min.js"></script>
<script src="/js/sb-admin-2.min.js"></script>
<!-- Page level plugins -->
<script src="../../vendor/chart.js/Chart.min.js"></script>
<script src="../../vendor/datatables/jquery.dataTables.min.js"></script>
<script src="../../vendor/datatables/dataTables.bootstrap4.min.js"></script>
<script src="/vendor/chart.js/Chart.min.js"></script>
<script src="/vendor/datatables/jquery.dataTables.min.js"></script>
<script src="/vendor/datatables/dataTables.bootstrap4.min.js"></script>
<!-- Page level custom scripts -->
<script src="../../js/demo/datatables-demo.js"></script>
<script src="../../js/demo/chart-area-demo.js"></script>
<script src="../../js/demo/chart-pie-demo.js"></script>
<script src="/js/demo/datatables-demo.js"></script>
<script src="/js/demo/chart-area-demo.js"></script>
<script src="/js/demo/chart-pie-demo.js"></script>

View File

@ -49,6 +49,24 @@
<span>Benutzer &amp; Gruppen</span></a>
</li>
<!-- Nav Item - Benutzer erstellen -->
<li class="nav-item<?= (isset($activeMenu) && $activeMenu === 'createuser') ? ' active' : '' ?>">
<a class="nav-link" href="../../index.php?route=createuser">
<i class="fas fa-fw fa-user-plus"></i>
<span>Benutzer erstellen</span></a>
</li>
<!-- Divider -->
<hr class="sidebar-divider d-none d-md-block">
<!-- Nav Item - Logs -->
<li class="nav-item<?= (isset($activeMenu) && $activeMenu === 'logs') ? ' active' : '' ?>">
<a class="nav-link" href="../../index.php?route=logs">
<i class="fas fa-fw fa-file-alt"></i>
<span>Logs</span>
</a>
</li>
<!-- Divider -->
<hr class="sidebar-divider d-none d-md-block">

View File

@ -25,6 +25,10 @@ declare(strict_types=1);
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Benutzer &amp; Gruppen</h1>
<a href="index.php?route=createuser" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm">
<i class="fas fa-user-plus fa-sm text-white-50"></i>
&nbsp;Benutzer erstellen
</a>
</div>
<?php if ($error !== null): ?>

View File

@ -0,0 +1,42 @@
This directory contains PowerShell scripts used by the PHP AdminTool for Active Directory user creation.
Usage (single user):
1. Create a JSON payload file (for example `payload.json`) with contents:
```
{
"samaccountname": "testuser",
"displayname": "Test User",
"mail": "testuser@example.local",
"password": "P@ssw0rd1234",
"ou": "OU=Users,DC=example,DC=local",
"groups": "Users,IT-Staff",
"dry_run": true
}
```
2. Run the script from PowerShell as a user with permission to create AD users (or use `dry_run` true to test):
```
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\create_user.ps1 -InputFile C:\temp\payload.json
```
Usage (CSV):
1. Create a CSV file with header `samaccountname,displayname,mail,password,ou,groups` (or no header and set `has_header: false` in meta JSON).
2. Create a meta JSON file containing the CSV path and options:
```
{
"input_file": "C:\temp\users.csv",
"delimiter": ",",
"has_header": true,
"dry_run": true
}
```
3. Run the CSV script:
```
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\create_users_csv.ps1 -InputFile C:\temp\meta.json
```
Notes:
- Ensure the `ActiveDirectory` PowerShell module is installed on the host system (RSAT).
- Test with `dry_run` set to `true` first to verify results without modifying AD.
- The scripts return a compact JSON object on stdout which the PHP backend expects.
- Run the webserver (IIS) as a user that has sufficient rights to run the `New-ADUser` and `Add-ADGroupMember` commands when `dry_run` is disabled.

View File

@ -0,0 +1,17 @@
# Returns JSON with information about the environment and AD module availability
Try {
$actor = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
} Catch {
$actor = $null
}
# Does the ActiveDirectory module exist?
$module = Get-Module -ListAvailable -Name ActiveDirectory -ErrorAction SilentlyContinue
$hasModule = $module -ne $null
# Is New-ADUser available?
$canNewAdUser = (Get-Command New-ADUser -ErrorAction SilentlyContinue) -ne $null
$output = @{ success = $true; actor = $actor; module_installed = $hasModule; can_new_aduser = $canNewAdUser }
Write-Output ($output | ConvertTo-Json -Compress)
exit 0

View File

@ -0,0 +1,153 @@
param(
[Parameter(Mandatory=$true)]
[string]$InputFile
)
# Read input JSON
try {
$json = Get-Content -Raw -Path $InputFile -ErrorAction Stop
$payload = $json | ConvertFrom-Json
} catch {
$err = $_.Exception.Message
Write-Output (@{ success = $false; message = "Failed to read/parse input JSON: $err" } | ConvertTo-Json -Compress)
exit 1
}
# Default result
$actor = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$result = @{ success = $false; message = "Unspecified error"; actor = $actor }
# Validate
if (-not $payload.samaccountname -or -not $payload.password) {
$result.message = "Required fields: samaccountname and password"
Write-Output ($result | ConvertTo-Json -Compress)
exit 1
}
# Convert to strings
$sam = [string]$payload.samaccountname
$display = [string]($payload.displayname)
$mail = [string]($payload.mail)
$pass = [string]$payload.password
$ou = [string]($payload.ou)
$groups = [string]($payload.groups)
$dryRun = [bool]($payload.dry_run -as [bool])
# Password hint and validation helper (German)
$passwordHint = @"
Das sollten die Anforderungen an das Passwort sein:
- mindestens 7 Zeichen
- darf den Benutzer-/Accountnamen nicht enthalten (bzw. keine zu großen Teile davon)
- muss Zeichen aus mindestens 3 von 4 Kategorien enthalten:
1) Großbuchstaben (AZ)
2) Kleinbuchstaben (az)
3) Ziffern (09)
4) Sonderzeichen (alles, was kein Buchstabe/Zahl ist, z. B. ! ? # _ - . , usw.)
"@
function Test-PasswordRequirements {
param(
[string]$Password,
[string]$SamAccountName
)
$errors = @()
if ([string]::IsNullOrEmpty($Password) -or $Password.Length -lt 7) {
$errors += 'Passwort muss mindestens 7 Zeichen lang sein.'
}
$categories = 0
if ($Password -match '[A-Z]') { $categories++ }
if ($Password -match '[a-z]') { $categories++ }
if ($Password -match '\d') { $categories++ }
if ($Password -match '[^A-Za-z0-9]') { $categories++ }
if ($categories -lt 3) {
$errors += 'Passwort muss Zeichen aus mindestens 3 von 4 Kategorien enthalten (Groß, Klein, Ziffern, Sonderzeichen).'
}
if (-not [string]::IsNullOrEmpty($SamAccountName)) {
$pwLower = $Password.ToLowerInvariant()
$samLower = $SamAccountName.ToLowerInvariant()
if ($pwLower -like "*${samLower}*") {
$errors += 'Passwort darf den Benutzernamen nicht enthalten.'
} else {
$minLen = 4
if ($samLower.Length -ge $minLen) {
for ($len = $minLen; $len -le $samLower.Length; $len++) {
for ($start = 0; $start -le $samLower.Length - $len; $start++) {
$sub = $samLower.Substring($start, $len)
if ($pwLower -like "*${sub}*") {
$errors += 'Passwort darf keine größeren Teile des Benutzernamens enthalten.'
break 2
}
}
}
}
}
}
return $errors
}
# Ensure ActiveDirectory module available
try {
Import-Module ActiveDirectory -ErrorAction Stop
} catch {
$result.message = "ActiveDirectory PowerShell module not available: $($_.Exception.Message)"
Write-Output ($result | ConvertTo-Json -Compress)
exit 1
}
# Build New-ADUser parameters
$props = @{
Name = if ($display -and $display -ne '') { $display } else { $sam }
SamAccountName = $sam
Enabled = $true
}
if ($mail -and $mail -ne '') { $props['EmailAddress'] = $mail }
if ($ou -and $ou -ne '') { $props['Path'] = $ou }
# Validate password before continuing
$pwErrors = Test-PasswordRequirements -Password $pass -SamAccountName $sam
if ($pwErrors.Count -gt 0) {
$result.message = 'Invalid password: ' + ($pwErrors -join ' | ')
$result.hint = $passwordHint
Write-Output ($result | ConvertTo-Json -Compress)
exit 1
}
# Build secure password
$securePass = ConvertTo-SecureString $pass -AsPlainText -Force
$props['AccountPassword'] = $securePass
# Execute
if ($dryRun) {
$result.success = $true
$result.message = "DRY RUN: would create user $($sam)"
Write-Output ($result | ConvertTo-Json -Compress)
exit 0
}
try {
# Create the AD user
New-ADUser @props -ErrorAction Stop
# Add to groups, if provided
if ($groups -and $groups -ne '') {
$groupList = $groups -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
foreach ($g in $groupList) {
Add-ADGroupMember -Identity $g -Members $sam -ErrorAction Stop
}
}
$result.success = $true
$result.message = "User $($sam) created successfully"
Write-Output ($result | ConvertTo-Json -Compress)
exit 0
} catch {
$result.message = "Error creating user $($sam): $($_.Exception.Message)"
Write-Output ($result | ConvertTo-Json -Compress)
exit 1
}

View File

@ -0,0 +1,176 @@
param(
[Parameter(Mandatory=$true)]
[string]$InputFile
)
# Read meta JSON
try {
$json = Get-Content -Raw -Path $InputFile -ErrorAction Stop
$meta = $json | ConvertFrom-Json
} catch {
Write-Output (@{ success = $false; message = "Failed to read/parse meta JSON: $($_.Exception.Message)" } | ConvertTo-Json -Compress)
exit 1
}
$csvFile = [string]$meta.input_file
# PowerShell 5.1 doesn't support the null-coalescing operator '??'.
# Use an explicit check here to set the default delimiter.
$delimiter = [string]$meta.delimiter
if ([string]::IsNullOrWhiteSpace($delimiter)) { $delimiter = ',' }
$hasHeader = [bool]($meta.has_header -as [bool])
$dryRun = [bool]($meta.dry_run -as [bool])
$defaultOu = [string]$meta.default_ou
if (-not (Test-Path -Path $csvFile)) {
Write-Output (@{ success = $false; message = "CSV file not found: $csvFile" } | ConvertTo-Json -Compress)
exit 1
}
# Ensure ActiveDirectory module is available
try {
Import-Module ActiveDirectory -ErrorAction Stop
} catch {
Write-Output (@{ success = $false; message = "ActiveDirectory PowerShell module not available: $($_.Exception.Message)" } | ConvertTo-Json -Compress)
exit 1
}
# Read CSV
try {
if ($hasHeader) {
$items = Import-Csv -Path $csvFile -Delimiter $delimiter -ErrorAction Stop
} else {
# Use default headers
$headers = 'samaccountname','displayname','mail','password','groups'
$items = Import-Csv -Path $csvFile -Delimiter $delimiter -Header $headers -ErrorAction Stop
}
} catch {
Write-Output (@{ success = $false; message = "Failed to parse CSV: $($_.Exception.Message)" } | ConvertTo-Json -Compress)
exit 1
}
$actor = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$results = @()
$successCount = 0
$failCount = 0
foreach ($row in $items) {
$sam = $row.samaccountname
$display = $row.displayname
$mail = $row.mail
$pass = $row.password
$groups = $row.groups
if ([string]::IsNullOrWhiteSpace($ou) -and -not [string]::IsNullOrWhiteSpace($defaultOu)) {
$ou = $defaultOu
}
# Password hint text (German)
$passwordHint = @"
Das sollten die Anforderungen an das Passwort sein:
- mindestens 7 Zeichen
- darf den Benutzer-/Accountnamen nicht enthalten (bzw. keine zu großen Teile davon)
- muss Zeichen aus mindestens 3 von 4 Kategorien enthalten:
1) Großbuchstaben (AZ)
2) Kleinbuchstaben (az)
3) Ziffern (09)
4) Sonderzeichen (alles, was kein Buchstabe/Zahl ist, z. B. ! ? # _ - . , usw.)
"@
function Test-PasswordRequirements {
param(
[string]$Password,
[string]$SamAccountName
)
$errors = @()
if ([string]::IsNullOrEmpty($Password) -or $Password.Length -lt 7) {
$errors += 'Passwort muss mindestens 7 Zeichen lang sein.'
}
# Categories: uppercase, lowercase, digit, special
$categories = 0
if ($Password -match '[A-Z]') { $categories++ }
if ($Password -match '[a-z]') { $categories++ }
if ($Password -match '\d') { $categories++ }
if ($Password -match '[^A-Za-z0-9]') { $categories++ }
if ($categories -lt 3) {
$errors += 'Passwort muss Zeichen aus mindestens 3 von 4 Kategorien enthalten (Groß, Klein, Ziffern, Sonderzeichen).'
}
# Check for username inclusion or large parts of it (case-insensitive)
if (-not [string]::IsNullOrEmpty($SamAccountName)) {
$pwLower = $Password.ToLowerInvariant()
$samLower = $SamAccountName.ToLowerInvariant()
if ($pwLower -like "*${samLower}*") {
$errors += 'Passwort darf den Benutzernamen nicht enthalten.'
} else {
# Check for substrings of username of length >= 4
$minLen = 4
if ($samLower.Length -ge $minLen) {
for ($len = $minLen; $len -le $samLower.Length; $len++) {
for ($start = 0; $start -le $samLower.Length - $len; $start++) {
$sub = $samLower.Substring($start, $len)
if ($pwLower -like "*${sub}*") {
$errors += 'Passwort darf keine größeren Teile des Benutzernamens enthalten.'
break 2
}
}
}
}
}
}
return $errors
}
if ([string]::IsNullOrWhiteSpace($sam) -or [string]::IsNullOrWhiteSpace($pass)) {
$results += @{ sam = $sam; success = $false; message = 'Missing samaccountname or password' }
$failCount++
continue
}
# Validate password according to requirements, also during dry run so issues are visible early
$pwErrors = Test-PasswordRequirements -Password $pass -SamAccountName $sam
if ($pwErrors.Count -gt 0) {
$results += @{ sam = $sam; success = $false; message = ('Invalid password: ' + ($pwErrors -join ' | ')); hint = $passwordHint }
$failCount++
continue
}
if ($dryRun) {
$results += @{ sam = $sam; success = $true; message = 'DRY RUN: would create (password validated)' }
$successCount++
continue
}
try {
$props = @{ Name = if ($display -and $display -ne '') { $display } else { $sam }; SamAccountName = $sam; Enabled = $true }
if ($mail -and $mail -ne '') { $props['EmailAddress'] = $mail }
if ($ou -and $ou -ne '') { $props['Path'] = $ou }
$securePass = ConvertTo-SecureString $pass -AsPlainText -Force
$props['AccountPassword'] = $securePass
New-ADUser @props -ErrorAction Stop
if ($groups -and $groups -ne '') {
$groupList = $groups -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
foreach ($g in $groupList) {
Add-ADGroupMember -Identity $g -Members $sam -ErrorAction Stop
}
}
$results += @{ sam = $sam; success = $true; message = 'Created' }
$successCount++
} catch {
$results += @{ sam = $sam; success = $false; message = $_.Exception.Message }
$failCount++
}
}
$output = @{ success = $failCount -eq 0; message = "Created $successCount users, $failCount failures"; details = $results; actor = $actor }
Write-Output ($output | ConvertTo-Json -Compress)
exit 0