Add shared auth login flow
This commit is contained in:
@@ -59,6 +59,27 @@ CREATE TABLE contact (
|
||||
|
||||
CREATE INDEX idx_contact_party ON contact(party_id);
|
||||
|
||||
CREATE TABLE system_user (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
failed_login_count INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMP,
|
||||
last_login_at TIMESTAMP,
|
||||
created_by_user_id BIGINT REFERENCES system_user(id) ON DELETE SET NULL,
|
||||
updated_by_user_id BIGINT REFERENCES system_user(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_system_user_role CHECK (role IN ('admin', 'user')),
|
||||
CONSTRAINT chk_system_user_failed_login_count CHECK (failed_login_count >= 0)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_system_user_username_ci ON system_user (LOWER(username));
|
||||
CREATE UNIQUE INDEX uq_system_user_email_ci ON system_user (LOWER(email));
|
||||
|
||||
-- Lagergefuehrtes Produkt (Bestandsfuehrung, Charge, MHD)
|
||||
CREATE TABLE product (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
@@ -41,6 +41,7 @@ Diese Referenz ordnet die aktuellen Tabellen, Views und technischen DB-Artefakte
|
||||
|
||||
- `audit_log`
|
||||
- `outbound_webhook_event`
|
||||
- `system_user`
|
||||
|
||||
## 3. Hinweise
|
||||
|
||||
@@ -48,4 +49,3 @@ Diese Referenz ordnet die aktuellen Tabellen, Views und technischen DB-Artefakte
|
||||
- Fremde Module schreiben nicht direkt in diese Artefakte.
|
||||
- `shared` besitzt keine fachlichen Tabellen.
|
||||
- Neue Tabellen fuer `buchhaltung` und `kundenberatung` werden erst mit deren Modulvertraegen definiert.
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../db.php';
|
||||
|
||||
const AUTH_SESSION_NAME = 'erp_naurua_session';
|
||||
const AUTH_LOGIN_RETURN_TO_KEY = 'auth_return_to';
|
||||
const AUTH_CSRF_TOKEN_KEY = 'auth_csrf_token';
|
||||
const AUTH_SESSION_USER_ID_KEY = 'auth_user_id';
|
||||
const AUTH_FAILED_LOGIN_LIMIT = 5;
|
||||
const AUTH_LOCK_DURATION_SECONDS = 900;
|
||||
|
||||
function auth_is_https_request(): bool
|
||||
{
|
||||
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$forwardedProto = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
|
||||
if ($forwardedProto === 'https') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function auth_bootstrap_session(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session_name(AUTH_SESSION_NAME);
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => auth_is_https_request(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_trans_sid', '0');
|
||||
session_start();
|
||||
|
||||
if (!isset($_SESSION[AUTH_CSRF_TOKEN_KEY]) || !is_string($_SESSION[AUTH_CSRF_TOKEN_KEY]) || $_SESSION[AUTH_CSRF_TOKEN_KEY] === '') {
|
||||
$_SESSION[AUTH_CSRF_TOKEN_KEY] = bin2hex(random_bytes(32));
|
||||
}
|
||||
}
|
||||
|
||||
function auth_csrf_token(): string
|
||||
{
|
||||
auth_bootstrap_session();
|
||||
return (string) $_SESSION[AUTH_CSRF_TOKEN_KEY];
|
||||
}
|
||||
|
||||
function auth_validate_csrf_token(?string $token): bool
|
||||
{
|
||||
auth_bootstrap_session();
|
||||
if (!is_string($token) || $token === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals((string) $_SESSION[AUTH_CSRF_TOKEN_KEY], $token);
|
||||
}
|
||||
|
||||
function auth_escape_html(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function auth_normalize_identifier(string $identifier): string
|
||||
{
|
||||
return trim($identifier);
|
||||
}
|
||||
|
||||
function auth_normalize_return_to(?string $returnTo): string
|
||||
{
|
||||
$candidate = trim((string) $returnTo);
|
||||
if ($candidate === '' || str_starts_with($candidate, '//') || str_contains($candidate, '://')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (!str_starts_with($candidate, '/')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
function auth_set_return_to(string $returnTo): void
|
||||
{
|
||||
auth_bootstrap_session();
|
||||
$_SESSION[AUTH_LOGIN_RETURN_TO_KEY] = auth_normalize_return_to($returnTo);
|
||||
}
|
||||
|
||||
function auth_take_return_to(): string
|
||||
{
|
||||
auth_bootstrap_session();
|
||||
$returnTo = auth_normalize_return_to($_SESSION[AUTH_LOGIN_RETURN_TO_KEY] ?? '/');
|
||||
unset($_SESSION[AUTH_LOGIN_RETURN_TO_KEY]);
|
||||
return $returnTo;
|
||||
}
|
||||
|
||||
function auth_ensure_schema(PDO $pdo): void
|
||||
{
|
||||
$requiredColumns = [
|
||||
'id',
|
||||
'username',
|
||||
'email',
|
||||
'password_hash',
|
||||
'role',
|
||||
'is_active',
|
||||
'failed_login_count',
|
||||
'locked_until',
|
||||
'last_login_at',
|
||||
'created_by_user_id',
|
||||
'updated_by_user_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'system_user'"
|
||||
);
|
||||
$columns = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
$existing = array_map('strval', $columns ?: []);
|
||||
$missing = array_values(array_diff($requiredColumns, $existing));
|
||||
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException('DB schema not initialized. Missing auth columns: ' . implode(', ', $missing));
|
||||
}
|
||||
}
|
||||
|
||||
function auth_fetch_user_row_by_id(PDO $pdo, int $userId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, username, email, role, is_active, locked_until, last_login_at, created_at, updated_at
|
||||
FROM public.system_user
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':id' => $userId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
function auth_current_user(PDO $pdo): ?array
|
||||
{
|
||||
auth_bootstrap_session();
|
||||
|
||||
$userId = $_SESSION[AUTH_SESSION_USER_ID_KEY] ?? null;
|
||||
if (!is_int($userId) && !ctype_digit((string) $userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = auth_fetch_user_row_by_id($pdo, (int) $userId);
|
||||
if ($user === null) {
|
||||
unset($_SESSION[AUTH_SESSION_USER_ID_KEY]);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!((bool) $user['is_active'])) {
|
||||
unset($_SESSION[AUTH_SESSION_USER_ID_KEY]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$lockedUntil = $user['locked_until'] ?? null;
|
||||
if ($lockedUntil !== null && $lockedUntil !== '') {
|
||||
try {
|
||||
$lockedUntilAt = new DateTimeImmutable((string) $lockedUntil);
|
||||
if ($lockedUntilAt > new DateTimeImmutable('now')) {
|
||||
unset($_SESSION[AUTH_SESSION_USER_ID_KEY]);
|
||||
return null;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
unset($_SESSION[AUTH_SESSION_USER_ID_KEY]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function auth_require_user(PDO $pdo): array
|
||||
{
|
||||
$user = auth_current_user($pdo);
|
||||
if ($user !== null) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
||||
auth_set_return_to($requestUri);
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
|
||||
function auth_find_user_by_identifier(PDO $pdo, string $identifier): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, username, email, password_hash, role, is_active, failed_login_count, locked_until
|
||||
FROM public.system_user
|
||||
WHERE LOWER(username) = LOWER(:identifier_username)
|
||||
OR LOWER(email) = LOWER(:identifier_email)
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
$stmt->execute([
|
||||
':identifier_username' => $identifier,
|
||||
':identifier_email' => $identifier,
|
||||
]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
function auth_login(PDO $pdo, string $identifier, string $password): array
|
||||
{
|
||||
auth_ensure_schema($pdo);
|
||||
auth_bootstrap_session();
|
||||
|
||||
$normalizedIdentifier = auth_normalize_identifier($identifier);
|
||||
$password = (string) $password;
|
||||
$errors = [
|
||||
'identifier' => null,
|
||||
'password' => null,
|
||||
];
|
||||
|
||||
if ($normalizedIdentifier === '') {
|
||||
$errors['identifier'] = 'Bitte Benutzername oder E-Mail eingeben.';
|
||||
}
|
||||
|
||||
if ($password === '') {
|
||||
$errors['password'] = 'Bitte Passwort eingeben.';
|
||||
}
|
||||
|
||||
if ($errors['identifier'] !== null || $errors['password'] !== null) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$user = auth_find_user_by_identifier($pdo, $normalizedIdentifier);
|
||||
if ($user === null) {
|
||||
$pdo->commit();
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'errors' => [
|
||||
'identifier' => 'Benutzername oder E-Mail ist unbekannt.',
|
||||
'password' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
if (!((bool) $user['is_active'])) {
|
||||
$pdo->commit();
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'errors' => [
|
||||
'identifier' => 'Benutzerkonto ist deaktiviert.',
|
||||
'password' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$lockedUntil = $user['locked_until'] ?? null;
|
||||
if ($lockedUntil !== null && $lockedUntil !== '') {
|
||||
try {
|
||||
$lockedUntilAt = new DateTimeImmutable((string) $lockedUntil);
|
||||
if ($lockedUntilAt > new DateTimeImmutable('now')) {
|
||||
$pdo->commit();
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'errors' => [
|
||||
'identifier' => 'Benutzerkonto ist vorübergehend gesperrt. Bitte später erneut versuchen.',
|
||||
'password' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$pdo->commit();
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'errors' => [
|
||||
'identifier' => 'Benutzerkonto ist vorübergehend gesperrt. Bitte später erneut versuchen.',
|
||||
'password' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!password_verify($password, (string) $user['password_hash'])) {
|
||||
$failedCount = (int) ($user['failed_login_count'] ?? 0) + 1;
|
||||
$lockedUntilSql = null;
|
||||
if ($failedCount >= AUTH_FAILED_LOGIN_LIMIT) {
|
||||
$lockedUntilSql = 'NOW() + INTERVAL \'' . AUTH_LOCK_DURATION_SECONDS . ' seconds\'';
|
||||
}
|
||||
|
||||
$updateSql = 'UPDATE public.system_user
|
||||
SET failed_login_count = :failed_login_count,
|
||||
locked_until = ' . ($lockedUntilSql !== null ? $lockedUntilSql : 'NULL') . ',
|
||||
updated_at = NOW(),
|
||||
updated_by_user_id = NULL
|
||||
WHERE id = :id';
|
||||
$stmt = $pdo->prepare($updateSql);
|
||||
$stmt->execute([
|
||||
':failed_login_count' => $failedCount,
|
||||
':id' => $userId,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'errors' => [
|
||||
'identifier' => null,
|
||||
'password' => 'Passwort ist falsch.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE public.system_user
|
||||
SET failed_login_count = 0,
|
||||
locked_until = NULL,
|
||||
last_login_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
updated_by_user_id = NULL
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute([':id' => $userId]);
|
||||
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[AUTH_SESSION_USER_ID_KEY] = $userId;
|
||||
$pdo->commit();
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'errors' => [
|
||||
'identifier' => null,
|
||||
'password' => null,
|
||||
],
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
function auth_logout(): void
|
||||
{
|
||||
auth_bootstrap_session();
|
||||
|
||||
$_SESSION = [];
|
||||
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, [
|
||||
'path' => $params['path'] ?? '/',
|
||||
'domain' => $params['domain'] ?? '',
|
||||
'secure' => (bool) ($params['secure'] ?? false),
|
||||
'httponly' => (bool) ($params['httponly'] ?? true),
|
||||
'samesite' => $params['samesite'] ?? 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
session_destroy();
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../service.php';
|
||||
|
||||
function render_auth_home_page(array $user): void
|
||||
{
|
||||
$userName = auth_escape_html((string) ($user['username'] ?? ''));
|
||||
|
||||
echo '<!doctype html>';
|
||||
echo '<html lang="de">';
|
||||
echo '<head>';
|
||||
echo '<meta charset="UTF-8">';
|
||||
echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
|
||||
echo '<title>Naurua ERP</title>';
|
||||
echo '<link rel="stylesheet" href="/assets/styles.css">';
|
||||
echo '</head>';
|
||||
echo '<body>';
|
||||
echo '<section id="pattern-portal-header" class="sg-vsf-list-overview-page-v2">';
|
||||
echo '<article class="sg-portal-header-pattern-variant" aria-label="Portal Header mit Options Row">';
|
||||
echo '<p class="sg-table-label sg-portal-header-pattern-variant__label sg-text-on-dark">Variante: Portal Header mit Options Row</p>';
|
||||
echo '<header class="sg-portal-header" aria-label="Portal Header" data-pattern="portal-header">';
|
||||
echo '<div class="sg-portal-header__main" data-pattern-part="portal-header-main">';
|
||||
echo '<p class="sg-portal-header__brand sg-brand-title" data-pattern-part="portal-header-brand">Naurua ERP</p>';
|
||||
echo '<div class="sg-portal-header__menu-wrap sg-sandwich-menu-wrap" data-open="false" data-component="sandwich-menu" data-component-size="default" data-component-context="portal-header" data-pattern-part="portal-header-action">';
|
||||
echo '<button class="sg-interaction-element sg-sandwich-button" type="button" aria-expanded="false" aria-label="Menü öffnen" data-component-part="sandwich-trigger">';
|
||||
echo '<span class="sg-sandwich-button__icon" aria-hidden="true">';
|
||||
echo '<span class="sg-sandwich-button__line"></span>';
|
||||
echo '<span class="sg-sandwich-button__line"></span>';
|
||||
echo '<span class="sg-sandwich-button__line"></span>';
|
||||
echo '</span>';
|
||||
echo '</button>';
|
||||
echo '<div class="sg-sandwich-menu-panel" aria-label="Ausgeklapptes Menü" data-component-part="sandwich-panel">';
|
||||
echo '<a class="sg-sandwich-menu-link" href="/" data-component-part="sandwich-menu-link">Startseite</a>';
|
||||
echo '<a class="sg-sandwich-menu-link" href="/logout.php" data-component-part="sandwich-menu-link">Abmelden</a>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '<nav class="sg-portal-header__tabs sg-tab-button-group" aria-label="Hauptnavigation" data-component="tab-navigation" data-component-size="large" data-component-context="portal-header" data-pattern-part="portal-header-navigation">';
|
||||
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Übersicht</button>';
|
||||
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="true" data-component-part="tab-button" data-component-state="active">Stammdaten</button>';
|
||||
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Bestellungen</button>';
|
||||
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Lager</button>';
|
||||
echo '</nav>';
|
||||
echo '</div>';
|
||||
echo '</header>';
|
||||
echo '<div class="sg-options-row" aria-label="Optionszeile" data-pattern="options-row">';
|
||||
echo '<div class="sg-options-row__left" data-pattern-part="options-row-primary-actions">';
|
||||
echo '<div class="sg-pulldown-demo" data-open="false" data-align="left" data-selection-mode="single" data-component="pulldown" data-component-context="options-row" data-component-state="inactive-selectable">';
|
||||
echo '<button class="sg-interaction-element sg-pulldown sg-pulldown-demo__trigger" type="button" aria-expanded="false" aria-label="Pulldown Sortierung" data-component-part="pulldown-trigger" data-label-base="Sortierung">Sortierung</button>';
|
||||
echo '<div class="sg-pulldown-panel" aria-label="Geöffnetes Pulldown Sortierung" data-component-part="pulldown-panel">';
|
||||
echo '<ul class="sg-pulldown-option-list" aria-label="Sortierungsoptionen">';
|
||||
echo '<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Menüpunkt 1</span></li>';
|
||||
echo '<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Menüpunkt 2</span></li>';
|
||||
echo '<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Menüpunkt 3</span></li>';
|
||||
echo '<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Menüpunkt 4</span></li>';
|
||||
echo '<li class="sg-pulldown-option sg-pulldown-option--disabled"><span>Menüpunkt 5</span></li>';
|
||||
echo '</ul>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '<div class="sg-pulldown-demo" data-open="false" data-align="left" data-selection-mode="single" data-component="pulldown" data-component-context="options-row" data-component-state="inactive-selectable">';
|
||||
echo '<button class="sg-interaction-element sg-pulldown sg-pulldown-demo__trigger" type="button" aria-expanded="false" aria-label="Pulldown Bereich" data-component-part="pulldown-trigger" data-label-base="Bereich">Bereich</button>';
|
||||
echo '<div class="sg-pulldown-panel" aria-label="Geöffnetes Pulldown Bereich" data-component-part="pulldown-panel">';
|
||||
echo '<ul class="sg-pulldown-option-list" aria-label="Bereichsoptionen">';
|
||||
echo '<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Menüpunkt 1</span></li>';
|
||||
echo '<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Menüpunkt 2</span></li>';
|
||||
echo '<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Menüpunkt 3</span></li>';
|
||||
echo '<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Menüpunkt 4</span></li>';
|
||||
echo '<li class="sg-pulldown-option sg-pulldown-option--disabled"><span>Menüpunkt 5</span></li>';
|
||||
echo '</ul>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '<div class="sg-search-field-row">';
|
||||
echo '<span class="sg-input-single-line-wrap sg-search-field-input" data-has-value="false" data-component="single-line-input" data-component-context="options-row" data-component-state="inactive-selectable">';
|
||||
echo '<input class="sg-interaction-element sg-input-single-line" type="text" placeholder="Suche" aria-label="Suche">';
|
||||
echo '<button class="sg-input-clear-button" type="button" aria-label="Eingabe löschen" data-component-part="input-clear-button">×</button>';
|
||||
echo '</span>';
|
||||
echo '<span class="sg-search-result-count sg-table-label" aria-live="polite" data-pattern-part="options-row-search-result-count">0 Treffer</span>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '<div class="sg-options-row__right" data-pattern-part="options-row-secondary-actions">';
|
||||
echo '<button class="sg-mode-toggle" type="button" data-active="relative" aria-label="Modus Schieber global: relativ aktiv" data-component="mode-toggle" data-component-context="options-row">';
|
||||
echo '<span class="sg-mode-toggle__label" data-component-part="toggle-label">absolut</span>';
|
||||
echo '<span class="sg-mode-toggle__switch" aria-hidden="true" data-component-part="toggle-track"><span class="sg-mode-toggle__handle" data-component-part="toggle-handle"></span></span>';
|
||||
echo '<span class="sg-mode-toggle__label" data-component-part="toggle-label">relativ</span>';
|
||||
echo '</button>';
|
||||
echo '<span class="sg-help-icon-wrap" data-open="false" data-align="left" data-component="help-icon" data-component-context="options-row">';
|
||||
echo '<button class="sg-help-icon" type="button" aria-expanded="false" aria-label="Hilfetext anzeigen" data-component-part="help-trigger">?</button>';
|
||||
echo '<span class="sg-help-icon-panel sg-table-label" role="tooltip" data-component-part="help-panel">Angemeldet als ' . $userName . '</span>';
|
||||
echo '</span>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '</article>';
|
||||
echo '<h1 class="sg-main-heading">Willkommen im Naurua ERP</h1>';
|
||||
echo '<section class="sg-left-navigation-pattern" aria-label="Linke Navigation">';
|
||||
echo '<div class="sg-left-navigation-pattern__layout" aria-label="Left Navigation Demo">';
|
||||
echo '<aside class="sg-group-card sg-left-navigation-pattern__group-card sg-left-navigation-pattern__group-card--navigation" data-component="group-card" aria-label="Navigation">';
|
||||
echo '<div class="sg-group-card__header-row sg-left-navigation-pattern__header-row">';
|
||||
echo '<h2 class="sg-heading-h2 sg-text-on-dark sg-group-card__heading">Navigation</h2>';
|
||||
echo '<button class="sg-interaction-element sg-button sg-button--active sg-left-navigation-pattern__toggle" type="button" data-left-navigation-toggle aria-expanded="true" aria-controls="left-navigation-menu">Ausblenden</button>';
|
||||
echo '</div>';
|
||||
echo '<nav class="sg-tab-button-group" id="left-navigation-menu" role="tablist" aria-label="Linksmenue Items" data-component="tab-navigation" data-component-size="large" data-component-variant="linksmenu-items">';
|
||||
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" role="tab" aria-selected="true" data-component-part="tab-button">Übersicht</button>';
|
||||
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" role="tab" aria-selected="false" data-component-part="tab-button">Kontakte</button>';
|
||||
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" role="tab" aria-selected="false" data-component-part="tab-button">Bestellungen</button>';
|
||||
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" role="tab" aria-selected="false" data-component-part="tab-button">Lager</button>';
|
||||
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" role="tab" aria-selected="false" data-component-part="tab-button">Einstellungen</button>';
|
||||
echo '</nav>';
|
||||
echo '</aside>';
|
||||
echo '<section class="sg-group-card sg-left-navigation-pattern__group-card sg-left-navigation-pattern__group-card--content" data-component="group-card" aria-hidden="true"></section>';
|
||||
echo '</div>';
|
||||
echo '</section>';
|
||||
echo '</section>';
|
||||
echo '<script src="/assets/help-icon-overlays.js"></script>';
|
||||
echo '<script>';
|
||||
echo "document.querySelectorAll('.sg-portal-header__tabs').forEach((group) => {";
|
||||
echo " group.querySelectorAll('.sg-tab-button').forEach((button) => {";
|
||||
echo " button.addEventListener('click', () => {";
|
||||
echo " group.querySelectorAll('.sg-tab-button').forEach((otherButton) => {";
|
||||
echo " const isActive = otherButton === button;";
|
||||
echo " otherButton.setAttribute('aria-selected', String(isActive));";
|
||||
echo " otherButton.dataset.componentState = isActive ? 'active' : 'inactive';";
|
||||
echo " });";
|
||||
echo " });";
|
||||
echo " });";
|
||||
echo "});";
|
||||
echo "document.querySelectorAll('.sg-sandwich-menu-wrap').forEach((wrap) => {";
|
||||
echo " const button = wrap.querySelector('.sg-sandwich-button');";
|
||||
echo " button.addEventListener('click', (event) => {";
|
||||
echo " event.stopPropagation();";
|
||||
echo " const nextState = wrap.dataset.open !== 'true';";
|
||||
echo " document.querySelectorAll('.sg-sandwich-menu-wrap').forEach((otherWrap) => {";
|
||||
echo " const otherButton = otherWrap.querySelector('.sg-sandwich-button');";
|
||||
echo " otherWrap.dataset.open = 'false';";
|
||||
echo " if (otherButton) { otherButton.setAttribute('aria-expanded', 'false'); }";
|
||||
echo " });";
|
||||
echo " wrap.dataset.open = String(nextState);";
|
||||
echo " button.setAttribute('aria-expanded', String(nextState));";
|
||||
echo " });";
|
||||
echo "});";
|
||||
echo "document.querySelectorAll('.sg-mode-toggle').forEach((toggle) => {";
|
||||
echo " toggle.addEventListener('click', () => {";
|
||||
echo " const nextState = toggle.dataset.active === 'relative' ? 'absolute' : 'relative';";
|
||||
echo " toggle.dataset.active = nextState;";
|
||||
echo " toggle.dataset.componentState = nextState;";
|
||||
echo " toggle.setAttribute('aria-label', 'Modus Schieber global: ' + (nextState === 'relative' ? 'relativ' : 'absolut') + ' aktiv');";
|
||||
echo " });";
|
||||
echo "});";
|
||||
echo "(() => {";
|
||||
echo " const mediaQuery = window.matchMedia('(max-width: 767px)');";
|
||||
echo " const toggle = document.querySelector('[data-left-navigation-toggle]');";
|
||||
echo " const menu = document.getElementById('left-navigation-menu');";
|
||||
echo " if (!toggle || !menu) { return; }";
|
||||
echo " const setMenuState = (expanded) => {";
|
||||
echo " menu.hidden = !expanded;";
|
||||
echo " menu.classList.toggle('sg-left-navigation-pattern__menu--collapsed', !expanded);";
|
||||
echo " toggle.setAttribute('aria-expanded', String(expanded));";
|
||||
echo " toggle.textContent = expanded ? 'Ausblenden' : 'Einblenden';";
|
||||
echo " toggle.classList.add('sg-button--active');";
|
||||
echo " toggle.classList.remove('sg-button--inactive');";
|
||||
echo " };";
|
||||
echo " const syncMode = () => {";
|
||||
echo " if (mediaQuery.matches) {";
|
||||
echo " if (toggle.getAttribute('aria-expanded') !== 'true' && toggle.getAttribute('aria-expanded') !== 'false') { setMenuState(true); return; }";
|
||||
echo " setMenuState(toggle.getAttribute('aria-expanded') !== 'false');";
|
||||
echo " return;";
|
||||
echo " }";
|
||||
echo " menu.hidden = false;";
|
||||
echo " menu.classList.remove('sg-left-navigation-pattern__menu--collapsed');";
|
||||
echo " toggle.setAttribute('aria-expanded', 'true');";
|
||||
echo " toggle.textContent = 'Ausblenden';";
|
||||
echo " toggle.classList.add('sg-button--active');";
|
||||
echo " toggle.classList.remove('sg-button--inactive');";
|
||||
echo " };";
|
||||
echo " toggle.addEventListener('click', () => { setMenuState(menu.hidden); });";
|
||||
echo " menu.querySelectorAll('.sg-tab-button').forEach((button) => {";
|
||||
echo " button.addEventListener('click', () => {";
|
||||
echo " menu.querySelectorAll('.sg-tab-button').forEach((otherButton) => {";
|
||||
echo " otherButton.setAttribute('aria-selected', String(otherButton === button));";
|
||||
echo " });";
|
||||
echo " });";
|
||||
echo " });";
|
||||
echo " syncMode();";
|
||||
echo " mediaQuery.addEventListener('change', syncMode);";
|
||||
echo "})();";
|
||||
echo '</script>';
|
||||
echo '</body>';
|
||||
echo '</html>';
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../service.php';
|
||||
|
||||
function render_auth_login_page(array $state): void
|
||||
{
|
||||
$identifierValue = auth_escape_html((string) ($state['identifier_value'] ?? ''));
|
||||
$identifierError = (string) ($state['errors']['identifier'] ?? '');
|
||||
$passwordError = (string) ($state['errors']['password'] ?? '');
|
||||
$pageTitle = 'Anmeldung zum Naurua ERP';
|
||||
$headerTitle = 'Willkommen im Naurua ERP';
|
||||
$subtitle = 'Mit Benutzername oder E-Mail anmelden';
|
||||
$csrf = auth_csrf_token();
|
||||
|
||||
$identifierInvalid = $identifierError !== '';
|
||||
$passwordInvalid = $passwordError !== '';
|
||||
|
||||
echo '<!doctype html>';
|
||||
echo '<html lang="de">';
|
||||
echo '<head>';
|
||||
echo '<meta charset="UTF-8">';
|
||||
echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
|
||||
echo '<title>' . auth_escape_html($pageTitle) . '</title>';
|
||||
echo '<link rel="stylesheet" href="/assets/styles.css">';
|
||||
echo '</head>';
|
||||
echo '<body class="sg-vsf-register-step-1-page">';
|
||||
|
||||
echo '<h1 class="sg-main-heading">Anmeldung – Naurua ERP</h1>';
|
||||
echo '<main class="sg-vsf-register-step-1">';
|
||||
echo '<article class="sg-card sg-object-card sg-object-card--variable-height sg-vsf-register-step-1__card" data-pattern="object-card" aria-label="Anmeldung">';
|
||||
echo '<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-object-card__header" data-pattern-part="object-card-header">';
|
||||
echo '<div class="sg-strong">' . auth_escape_html($headerTitle) . '</div>';
|
||||
echo '</header>';
|
||||
echo '<footer class="sg-card-segment sg-card-segment--gray" aria-label="Anmeldeformular">';
|
||||
echo '<div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Formular mit Abschnitten">';
|
||||
echo '<form class="sg-form-sections-card" action="/" method="post" novalidate>';
|
||||
echo '<input type="hidden" name="csrf_token" value="' . auth_escape_html($csrf) . '">';
|
||||
echo '<div class="sg-form-sections-card__body" data-pattern-part="form-body">';
|
||||
echo '<h2 class="sg-strong sg-form-sections-card__title">' . auth_escape_html($subtitle) . '</h2>';
|
||||
echo '<div class="sg-form-sections-card__field-group">';
|
||||
|
||||
echo '<label class="sg-labeled-input-row">';
|
||||
echo '<span class="sg-label">Benutzername oder E-Mail</span>';
|
||||
echo '<span class="sg-input-validation-stack">';
|
||||
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" name="identifier" value="' . $identifierValue . '" aria-label="Benutzername oder E-Mail" autocomplete="username" autocapitalize="none" spellcheck="false"';
|
||||
if ($identifierInvalid) {
|
||||
echo ' aria-invalid="true" aria-describedby="login-identifier-error"';
|
||||
}
|
||||
echo '>';
|
||||
if ($identifierInvalid) {
|
||||
echo '<span class="sg-form-validation-text" id="login-identifier-error">' . auth_escape_html($identifierError) . '</span>';
|
||||
}
|
||||
echo '</span>';
|
||||
echo '</label>';
|
||||
|
||||
echo '<label class="sg-labeled-input-row">';
|
||||
echo '<span class="sg-label">Passwort</span>';
|
||||
echo '<span class="sg-input-validation-stack">';
|
||||
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="password" name="password" aria-label="Passwort" autocomplete="current-password"';
|
||||
if ($passwordInvalid) {
|
||||
echo ' aria-invalid="true" aria-describedby="login-password-error"';
|
||||
}
|
||||
echo '>';
|
||||
if ($passwordInvalid) {
|
||||
echo '<span class="sg-form-validation-text" id="login-password-error">' . auth_escape_html($passwordError) . '</span>';
|
||||
}
|
||||
echo '</span>';
|
||||
echo '</label>';
|
||||
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
echo '<footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment">';
|
||||
echo '<div class="sg-form-sections-card__actions" data-pattern-part="form-actions">';
|
||||
echo '<button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="reset">Abbrechen</button>';
|
||||
echo '<button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Anmelden</button>';
|
||||
echo '</div>';
|
||||
echo '</footer>';
|
||||
echo '</form>';
|
||||
echo '</div>';
|
||||
echo '</footer>';
|
||||
echo '</article>';
|
||||
echo '</main>';
|
||||
echo '</body>';
|
||||
echo '</html>';
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
(function initHelpIconOverlayModule() {
|
||||
const CLOSE_HANDLERS = {
|
||||
'.sg-pulldown-demo': (root) => {
|
||||
root.querySelectorAll('.sg-pulldown-demo').forEach((demo) => {
|
||||
const trigger = demo.querySelector('.sg-pulldown-demo__trigger');
|
||||
demo.dataset.open = 'false';
|
||||
if (trigger) {
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
},
|
||||
'.sg-sandwich-menu-wrap': (root) => {
|
||||
root.querySelectorAll('.sg-sandwich-menu-wrap').forEach((wrap) => {
|
||||
const button = wrap.querySelector('.sg-sandwich-button');
|
||||
wrap.dataset.open = 'false';
|
||||
if (button) {
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const getViewportWidth = () => {
|
||||
if (window.visualViewport && typeof window.visualViewport.width === 'number') {
|
||||
return window.visualViewport.width;
|
||||
}
|
||||
return window.innerWidth;
|
||||
};
|
||||
|
||||
const getSafeInsetPx = () => {
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const spacingSmallRaw = rootStyles.getPropertyValue('--spacing-small').trim();
|
||||
const rootFontSize = parseFloat(rootStyles.fontSize) || 16;
|
||||
const spacingSmallValue = parseFloat(spacingSmallRaw);
|
||||
if (Number.isNaN(spacingSmallValue)) {
|
||||
return 0;
|
||||
}
|
||||
if (spacingSmallRaw.endsWith('rem')) {
|
||||
return spacingSmallValue * rootFontSize;
|
||||
}
|
||||
return spacingSmallValue;
|
||||
};
|
||||
|
||||
const closeAllHelpIcons = (root) => {
|
||||
root.querySelectorAll('.sg-help-icon-wrap').forEach((wrap) => {
|
||||
const button = wrap.querySelector('.sg-help-icon');
|
||||
const panel = wrap.querySelector('.sg-help-icon-panel');
|
||||
wrap.dataset.open = 'false';
|
||||
if (panel) {
|
||||
panel.style.removeProperty('transform');
|
||||
}
|
||||
if (button) {
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.sgInitHelpIconOverlays = (options = {}) => {
|
||||
const root = options.root || document;
|
||||
const closeOnOpenSelectors = options.closeOnOpenSelectors || [];
|
||||
const outsideClickIgnoreSelectors = options.outsideClickIgnoreSelectors || [];
|
||||
|
||||
root.querySelectorAll('.sg-help-icon-wrap').forEach((wrap) => {
|
||||
if (wrap.dataset.helpIconInit === 'true') {
|
||||
return;
|
||||
}
|
||||
wrap.dataset.helpIconInit = 'true';
|
||||
|
||||
const button = wrap.querySelector('.sg-help-icon');
|
||||
const panel = wrap.querySelector('.sg-help-icon-panel');
|
||||
if (!button || !panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const nextState = wrap.dataset.open !== 'true';
|
||||
|
||||
closeAllHelpIcons(root);
|
||||
closeOnOpenSelectors.forEach((selector) => {
|
||||
const handler = CLOSE_HANDLERS[selector];
|
||||
if (handler) {
|
||||
handler(root);
|
||||
}
|
||||
});
|
||||
|
||||
if (!nextState) {
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.dataset.align = 'left';
|
||||
wrap.dataset.open = 'true';
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
|
||||
const viewportWidth = getViewportWidth();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
if (panelRect.right > viewportWidth) {
|
||||
wrap.dataset.align = 'right';
|
||||
}
|
||||
const alignedPanelRect = panel.getBoundingClientRect();
|
||||
if (alignedPanelRect.left < 0) {
|
||||
wrap.dataset.align = 'left';
|
||||
}
|
||||
|
||||
const clampedRect = panel.getBoundingClientRect();
|
||||
const safeInset = getSafeInsetPx();
|
||||
let shiftX = 0;
|
||||
if (clampedRect.right > (viewportWidth - safeInset)) {
|
||||
shiftX -= clampedRect.right - (viewportWidth - safeInset);
|
||||
}
|
||||
if ((clampedRect.left + shiftX) < safeInset) {
|
||||
shiftX += safeInset - (clampedRect.left + shiftX);
|
||||
}
|
||||
if (shiftX !== 0) {
|
||||
panel.style.transform = `translateX(${shiftX}px)`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const isInsideIgnoredZone = ['.sg-help-icon-wrap', ...outsideClickIgnoreSelectors]
|
||||
.some((selector) => event.target.closest(selector));
|
||||
if (isInsideIgnoredZone) {
|
||||
return;
|
||||
}
|
||||
closeAllHelpIcons(root);
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../modules/shared/auth/service.php';
|
||||
require_once __DIR__ . '/../modules/shared/auth/ui/login.php';
|
||||
require_once __DIR__ . '/../modules/shared/auth/ui/home.php';
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
|
||||
$pdo = connect_database($env);
|
||||
auth_bootstrap_session();
|
||||
auth_ensure_schema($pdo);
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
$csrfToken = (string) ($_POST['csrf_token'] ?? '');
|
||||
if (!auth_validate_csrf_token($csrfToken)) {
|
||||
render_auth_login_page([
|
||||
'identifier_value' => (string) ($_POST['identifier'] ?? ''),
|
||||
'errors' => [
|
||||
'identifier' => 'Ungültiges Sicherheits-Token. Bitte Seite neu laden.',
|
||||
'password' => null,
|
||||
],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$loginResult = auth_login(
|
||||
$pdo,
|
||||
(string) ($_POST['identifier'] ?? ''),
|
||||
(string) ($_POST['password'] ?? '')
|
||||
);
|
||||
|
||||
if (($loginResult['ok'] ?? false) === true) {
|
||||
header('Location: ' . auth_take_return_to());
|
||||
exit;
|
||||
}
|
||||
|
||||
render_auth_login_page([
|
||||
'identifier_value' => (string) ($_POST['identifier'] ?? ''),
|
||||
'errors' => $loginResult['errors'] ?? [],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUser = auth_current_user($pdo);
|
||||
if ($currentUser !== null) {
|
||||
render_auth_home_page($currentUser);
|
||||
exit;
|
||||
}
|
||||
|
||||
render_auth_login_page([
|
||||
'identifier_value' => '',
|
||||
'errors' => [
|
||||
'identifier' => null,
|
||||
'password' => null,
|
||||
],
|
||||
]);
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../modules/shared/auth/service.php';
|
||||
|
||||
auth_bootstrap_session();
|
||||
auth_logout();
|
||||
header('Location: /');
|
||||
exit;
|
||||
@@ -1,4 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../modules/shared/auth/service.php';
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
|
||||
$pdo = connect_database($env);
|
||||
auth_bootstrap_session();
|
||||
auth_ensure_schema($pdo);
|
||||
auth_require_user($pdo);
|
||||
|
||||
require_once __DIR__ . '/../../modules/erp/direktverkauf/ui/index.php';
|
||||
|
||||
Reference in New Issue
Block a user