Phase1 Bearbeitung
This commit is contained in:
@@ -4,6 +4,8 @@ APP_DEBUG=0
|
|||||||
APP_URL=http://localhost:8080
|
APP_URL=http://localhost:8080
|
||||||
APP_TIMEZONE=Europe/Berlin
|
APP_TIMEZONE=Europe/Berlin
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
|
ALLOW_PUBLIC_REGISTRATION=0
|
||||||
|
RFID_INGEST_ENABLED=0
|
||||||
|
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
@@ -12,4 +14,21 @@ DB_USER=root
|
|||||||
DB_PASS=
|
DB_PASS=
|
||||||
|
|
||||||
MAIL_FROM=noreply@example.com
|
MAIL_FROM=noreply@example.com
|
||||||
|
MAIL_REPLY_TO=
|
||||||
|
MAIL_SUBJECT_PREFIX="[Kaffeekasse] "
|
||||||
|
MAIL_LOG_FALLBACK=1
|
||||||
|
MAIL_LOG_FILE=
|
||||||
|
|
||||||
|
PASSWORD_RESET_URL=http://localhost:8080/reset-password?token={{token}}
|
||||||
|
PASSWORD_RESET_TTL_MINUTES=60
|
||||||
|
PASSWORD_RESET_MAX_ACTIVE_TOKENS=3
|
||||||
|
PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen"
|
||||||
|
|
||||||
|
RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5
|
||||||
|
RATE_LIMIT_LOGIN_WINDOW_SECONDS=900
|
||||||
|
RATE_LIMIT_LOGIN_BLOCK_SECONDS=900
|
||||||
|
RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3
|
||||||
|
RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600
|
||||||
|
RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600
|
||||||
|
|
||||||
RFID_SHARED_SECRET=change-me
|
RFID_SHARED_SECRET=change-me
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ use App\Core\Csrf;
|
|||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Core\Response;
|
use App\Core\Response;
|
||||||
use App\Core\Session;
|
use App\Core\Session;
|
||||||
|
use App\Core\TenantContext;
|
||||||
use App\Core\View;
|
use App\Core\View;
|
||||||
use App\Services\AuditService;
|
use App\Services\AuditService;
|
||||||
|
use App\Services\MailerService;
|
||||||
|
use App\Services\PasswordResetService;
|
||||||
|
use App\Services\RateLimiterService;
|
||||||
use App\Services\SetupService;
|
use App\Services\SetupService;
|
||||||
use App\Services\TenantRegistrationService;
|
use App\Services\TenantRegistrationService;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -47,6 +51,7 @@ final class PlatformController
|
|||||||
'title' => 'Kaffeekasse fuer Teams, Vereine und Coworking',
|
'title' => 'Kaffeekasse fuer Teams, Vereine und Coworking',
|
||||||
'setupComplete' => $setup->isInstalled(),
|
'setupComplete' => $setup->isInstalled(),
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
|
'allowPublicRegistration' => $this->config['allow_public_registration'] ?? false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +91,11 @@ final class PlatformController
|
|||||||
Response::redirect('/install');
|
Response::redirect('/install');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$this->allowsPublicRegistration()) {
|
||||||
|
$this->session->flash('error', 'Die offene Registrierung ist derzeit deaktiviert. Fuer einen Pilotzugang bitte direkt den Betreiber kontaktieren.');
|
||||||
|
Response::redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
$this->view->render('home/register', [
|
$this->view->render('home/register', [
|
||||||
'title' => 'Mandant registrieren',
|
'title' => 'Mandant registrieren',
|
||||||
]);
|
]);
|
||||||
@@ -96,13 +106,31 @@ final class PlatformController
|
|||||||
$this->assertCsrf();
|
$this->assertCsrf();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!$this->allowsPublicRegistration()) {
|
||||||
|
throw new RuntimeException('Die offene Registrierung ist derzeit deaktiviert.');
|
||||||
|
}
|
||||||
|
|
||||||
$pdo = $this->database->pdo();
|
$pdo = $this->database->pdo();
|
||||||
|
$rateLimiter = $this->rateLimiter($pdo);
|
||||||
|
$email = mb_strtolower(trim((string) ($_POST['owner_email'] ?? '')));
|
||||||
|
$scopes = $this->buildScopes('registration', $email);
|
||||||
|
|
||||||
|
$this->ensureAllowed($rateLimiter, 'registration', $scopes);
|
||||||
|
|
||||||
$audit = new AuditService($pdo);
|
$audit = new AuditService($pdo);
|
||||||
$service = new TenantRegistrationService($pdo, $audit);
|
$service = new TenantRegistrationService($pdo, $audit);
|
||||||
$slug = $service->register($_POST);
|
$slug = $service->register($_POST);
|
||||||
|
$this->clearScopes($rateLimiter, 'registration', $scopes);
|
||||||
$this->session->flash('success', 'Dein Mandant wurde angelegt. Du kannst dich jetzt anmelden.');
|
$this->session->flash('success', 'Dein Mandant wurde angelegt. Du kannst dich jetzt anmelden.');
|
||||||
Response::redirect('/t/' . rawurlencode($slug) . '/login');
|
Response::redirect('/t/' . rawurlencode($slug) . '/login');
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
|
if ($this->database->isConfigured()) {
|
||||||
|
try {
|
||||||
|
$this->registerFailure('registration', mb_strtolower(trim((string) ($_POST['owner_email'] ?? ''))));
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
remember_old_input($_POST);
|
remember_old_input($_POST);
|
||||||
$this->session->flash('error', $throwable->getMessage());
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
Response::redirect('/register');
|
Response::redirect('/register');
|
||||||
@@ -122,17 +150,25 @@ final class PlatformController
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = $this->database->pdo();
|
$pdo = $this->database->pdo();
|
||||||
|
$email = mb_strtolower(trim((string) ($_POST['email'] ?? '')));
|
||||||
|
$rateLimiter = $this->rateLimiter($pdo);
|
||||||
|
$scopes = $this->buildScopes('login', 'platform|' . $email);
|
||||||
|
|
||||||
|
$this->ensureAllowed($rateLimiter, 'login', $scopes);
|
||||||
|
|
||||||
$auth = new Auth($pdo, $this->session);
|
$auth = new Auth($pdo, $this->session);
|
||||||
$audit = new AuditService($pdo);
|
$audit = new AuditService($pdo);
|
||||||
$success = $auth->attemptPlatformLogin((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
|
$success = $auth->attemptPlatformLogin((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
|
||||||
$audit->log(null, null, 'platform.login', 'user', null, $success ? 'success' : 'failure', [
|
$audit->log(null, null, 'platform.login', 'user', null, $success ? 'success' : 'failure', [
|
||||||
'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))),
|
'email' => $email,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$success) {
|
if (!$success) {
|
||||||
|
$this->hitScopes($rateLimiter, 'login', $scopes);
|
||||||
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
|
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->clearScopes($rateLimiter, 'login', $scopes);
|
||||||
$this->session->flash('success', 'Willkommen im Plattformbereich.');
|
$this->session->flash('success', 'Willkommen im Plattformbereich.');
|
||||||
Response::redirect('/admin');
|
Response::redirect('/admin');
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
@@ -142,6 +178,108 @@ final class PlatformController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function passwordResetRequestForm(): void
|
||||||
|
{
|
||||||
|
$scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform'));
|
||||||
|
$tenantSlug = trim((string) ($_GET['tenant'] ?? ''));
|
||||||
|
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
|
||||||
|
|
||||||
|
$this->view->render('auth/reset-request', [
|
||||||
|
'title' => 'Passwort vergessen',
|
||||||
|
'scope' => $scope,
|
||||||
|
'tenantSlug' => $tenantSlug,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function passwordResetRequestSubmit(): void
|
||||||
|
{
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
|
||||||
|
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
|
||||||
|
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
|
||||||
|
$service = new PasswordResetService(
|
||||||
|
$pdo,
|
||||||
|
new MailerService($this->config['mail'] ?? []),
|
||||||
|
$this->config['password_reset'] ?? []
|
||||||
|
);
|
||||||
|
$service->requestReset((string) ($_POST['email'] ?? ''), $this->passwordResetContext($scope, $tenant, $tenantSlug));
|
||||||
|
$this->session->flash('success', 'Falls ein Konto existiert, wurde ein Link zum Zuruecksetzen versendet.');
|
||||||
|
Response::redirect($this->passwordResetRequestUrl($scope, $tenant, $tenantSlug));
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
|
||||||
|
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
|
||||||
|
Response::redirect($this->passwordResetRequestUrl($scope, null, $tenantSlug));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function passwordResetConfirmForm(): void
|
||||||
|
{
|
||||||
|
$scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform'));
|
||||||
|
$tenantSlug = trim((string) ($_GET['tenant'] ?? ''));
|
||||||
|
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
|
||||||
|
$token = (string) ($_GET['token'] ?? '');
|
||||||
|
$resetToken = null;
|
||||||
|
|
||||||
|
if ($this->database->isConfigured() && trim($token) !== '') {
|
||||||
|
try {
|
||||||
|
$resetToken = $this->passwordResetService($this->database->pdo())->findValidToken($token);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$resetToken = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view->render('auth/reset-confirm', [
|
||||||
|
'title' => 'Neues Passwort setzen',
|
||||||
|
'scope' => $scope,
|
||||||
|
'tenantSlug' => $tenantSlug,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'token' => $token,
|
||||||
|
'resetToken' => $resetToken,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function passwordResetConfirmSubmit(): void
|
||||||
|
{
|
||||||
|
$this->assertCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
|
||||||
|
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
|
||||||
|
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
|
||||||
|
$password = (string) ($_POST['password'] ?? '');
|
||||||
|
$passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
|
||||||
|
|
||||||
|
if ($password !== $passwordConfirm) {
|
||||||
|
throw new RuntimeException('Die Passwort-Bestaetigung stimmt nicht ueberein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$service = $this->passwordResetService($pdo);
|
||||||
|
$userId = $service->resetPassword(
|
||||||
|
(string) ($_POST['token'] ?? ''),
|
||||||
|
$password
|
||||||
|
);
|
||||||
|
|
||||||
|
(new AuditService($pdo))->log(null, $userId, 'password.reset', 'user', $userId, 'success');
|
||||||
|
|
||||||
|
$this->session->flash('success', 'Das Passwort wurde aktualisiert. Du kannst dich jetzt anmelden.');
|
||||||
|
Response::redirect($this->passwordResetLoginUrl($scope, $tenant, $tenantSlug));
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
remember_old_input($_POST);
|
||||||
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
|
$token = trim((string) ($_POST['token'] ?? ''));
|
||||||
|
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
|
||||||
|
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
|
||||||
|
Response::redirect($this->passwordResetConfirmUrl($token, $scope, null, $tenantSlug));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function adminDashboard(): void
|
public function adminDashboard(): void
|
||||||
{
|
{
|
||||||
$pdo = $this->database->pdo();
|
$pdo = $this->database->pdo();
|
||||||
@@ -186,4 +324,135 @@ final class PlatformController
|
|||||||
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.');
|
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function allowsPublicRegistration(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->config['allow_public_registration'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rateLimiter(\PDO $pdo): RateLimiterService
|
||||||
|
{
|
||||||
|
return new RateLimiterService($pdo, $this->config['rate_limits'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passwordResetService(\PDO $pdo): PasswordResetService
|
||||||
|
{
|
||||||
|
return new PasswordResetService(
|
||||||
|
$pdo,
|
||||||
|
new MailerService($this->config['mail'] ?? []),
|
||||||
|
$this->config['password_reset'] ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildScopes(string $action, string $identifier = ''): array
|
||||||
|
{
|
||||||
|
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
|
||||||
|
$scopes = [$action . '|ip|' . $ip];
|
||||||
|
|
||||||
|
if (trim($identifier) !== '') {
|
||||||
|
$scopes[] = $action . '|subject|' . mb_strtolower(trim($identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureAllowed(RateLimiterService $rateLimiter, string $action, array $scopes): void
|
||||||
|
{
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
$state = $rateLimiter->inspect($action, $scope);
|
||||||
|
|
||||||
|
if (!$state['allowed']) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Zu viele Versuche. Bitte in etwa ' . (int) $state['retry_after_seconds'] . ' Sekunden erneut versuchen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hitScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void
|
||||||
|
{
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
$rateLimiter->hit($action, $scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void
|
||||||
|
{
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
$rateLimiter->clear($action, $scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerFailure(string $action, string $identifier = ''): void
|
||||||
|
{
|
||||||
|
$pdo = $this->database->pdo();
|
||||||
|
$rateLimiter = $this->rateLimiter($pdo);
|
||||||
|
|
||||||
|
foreach ($this->buildScopes($action, $identifier) as $scope) {
|
||||||
|
$rateLimiter->hit($action, $scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePasswordResetScope(string $scope): string
|
||||||
|
{
|
||||||
|
return trim($scope) === 'tenant' ? 'tenant' : 'platform';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passwordResetTenant(string $scope, string $tenantSlug): ?array
|
||||||
|
{
|
||||||
|
$tenantSlug = trim($tenantSlug);
|
||||||
|
|
||||||
|
if ($scope !== 'tenant' || $tenantSlug === '' || !$this->database->isConfigured()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = (new TenantContext($this->database->pdo()))->bySlug($tenantSlug);
|
||||||
|
|
||||||
|
return is_array($tenant) ? $tenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passwordResetContext(string $scope, ?array $tenant, string $tenantSlug): array
|
||||||
|
{
|
||||||
|
$context = ['scope' => $scope];
|
||||||
|
$resolvedSlug = $tenant['slug'] ?? trim($tenantSlug);
|
||||||
|
|
||||||
|
if ($scope === 'tenant' && is_string($resolvedSlug) && $resolvedSlug !== '') {
|
||||||
|
$context['tenant'] = $resolvedSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passwordResetRequestUrl(string $scope, ?array $tenant, string $tenantSlug): string
|
||||||
|
{
|
||||||
|
$query = http_build_query($this->passwordResetContext($scope, $tenant, $tenantSlug), '', '&', PHP_QUERY_RFC3986);
|
||||||
|
|
||||||
|
return '/password/forgot' . ($query !== '' ? '?' . $query : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passwordResetConfirmUrl(string $token, string $scope, ?array $tenant, string $tenantSlug): string
|
||||||
|
{
|
||||||
|
$query = $this->passwordResetContext($scope, $tenant, $tenantSlug);
|
||||||
|
|
||||||
|
if (trim($token) !== '') {
|
||||||
|
$query['token'] = $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
|
||||||
|
return '/reset-password' . ($encoded !== '' ? '?' . $encoded : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passwordResetLoginUrl(string $scope, ?array $tenant, string $tenantSlug): string
|
||||||
|
{
|
||||||
|
if ($scope === 'tenant') {
|
||||||
|
$resolvedSlug = $tenant['slug'] ?? trim($tenantSlug);
|
||||||
|
|
||||||
|
if (is_string($resolvedSlug) && $resolvedSlug !== '') {
|
||||||
|
return tenant_url($resolvedSlug, 'login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/admin/login';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use App\Core\Session;
|
|||||||
use App\Core\TenantContext;
|
use App\Core\TenantContext;
|
||||||
use App\Core\View;
|
use App\Core\View;
|
||||||
use App\Services\AuditService;
|
use App\Services\AuditService;
|
||||||
|
use App\Services\ExportService;
|
||||||
use App\Services\LedgerService;
|
use App\Services\LedgerService;
|
||||||
|
use App\Services\RateLimiterService;
|
||||||
use App\Services\RfidService;
|
use App\Services\RfidService;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -20,7 +22,8 @@ final class TenantController
|
|||||||
private readonly Database $database,
|
private readonly Database $database,
|
||||||
private readonly View $view,
|
private readonly View $view,
|
||||||
private readonly Session $session,
|
private readonly Session $session,
|
||||||
private readonly Csrf $csrf
|
private readonly Csrf $csrf,
|
||||||
|
private readonly array $config = []
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,24 +40,43 @@ final class TenantController
|
|||||||
public function loginSubmit(string $tenantSlug): void
|
public function loginSubmit(string $tenantSlug): void
|
||||||
{
|
{
|
||||||
$this->assertCsrf();
|
$this->assertCsrf();
|
||||||
$tenant = $this->tenant($tenantSlug);
|
|
||||||
$pdo = $this->database->pdo();
|
|
||||||
$auth = new Auth($pdo, $this->session);
|
|
||||||
$audit = new AuditService($pdo);
|
|
||||||
|
|
||||||
$success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
|
try {
|
||||||
$audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [
|
$tenant = $this->tenant($tenantSlug);
|
||||||
'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))),
|
$pdo = $this->database->pdo();
|
||||||
]);
|
$auth = new Auth($pdo, $this->session);
|
||||||
|
$audit = new AuditService($pdo);
|
||||||
|
$email = mb_strtolower(trim((string) ($_POST['email'] ?? '')));
|
||||||
|
$rateLimiter = $this->rateLimiter($pdo);
|
||||||
|
$scopes = $this->buildRateLimitScopes($tenantSlug, $email);
|
||||||
|
|
||||||
if (!$success) {
|
$this->ensureRateLimitAllowed($rateLimiter, $scopes);
|
||||||
|
|
||||||
|
$success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
|
||||||
|
$audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [
|
||||||
|
'email' => $email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$success) {
|
||||||
|
$this->hitRateLimitScopes($rateLimiter, $scopes);
|
||||||
|
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$authState = $auth->user() ?? [];
|
||||||
|
|
||||||
|
if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) {
|
||||||
|
$auth->logout();
|
||||||
|
throw new RuntimeException('Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->clearRateLimitScopes($rateLimiter, $scopes);
|
||||||
|
$this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.');
|
||||||
|
Response::redirect(tenant_url($tenantSlug));
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
remember_old_input($_POST);
|
remember_old_input($_POST);
|
||||||
$this->session->flash('error', 'Die Zugangsdaten konnten nicht verifiziert werden.');
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
Response::redirect(tenant_url($tenantSlug, 'login'));
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.');
|
|
||||||
Response::redirect(tenant_url($tenantSlug));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout(string $tenantSlug): void
|
public function logout(string $tenantSlug): void
|
||||||
@@ -71,54 +93,144 @@ final class TenantController
|
|||||||
{
|
{
|
||||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
||||||
$pdo = $this->database->pdo();
|
$pdo = $this->database->pdo();
|
||||||
|
$isManager = $this->isTenantManager($authState);
|
||||||
|
$currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState);
|
||||||
|
|
||||||
$summaryStatement = $pdo->prepare(
|
if ($isManager) {
|
||||||
'SELECT
|
$summaryStatement = $pdo->prepare(
|
||||||
COUNT(DISTINCT members.id) AS member_count,
|
'SELECT
|
||||||
COUNT(DISTINCT products.id) AS product_count,
|
COUNT(DISTINCT members.id) AS member_count,
|
||||||
COUNT(DISTINCT paper_sheets.id) AS paper_sheet_count,
|
COUNT(DISTINCT products.id) AS product_count,
|
||||||
COALESCE(SUM(CASE WHEN ledger_lines.account_code = "member_balance" THEN ledger_lines.amount_cents ELSE 0 END), 0) AS open_balance_cents
|
COUNT(DISTINCT paper_sheets.id) AS paper_sheet_count,
|
||||||
FROM tenants
|
COALESCE(SUM(CASE WHEN ledger_lines.account_code = "member_balance" THEN ledger_lines.amount_cents ELSE 0 END), 0) AS open_balance_cents
|
||||||
LEFT JOIN members ON members.tenant_id = tenants.id AND members.status = "active"
|
FROM tenants
|
||||||
LEFT JOIN products ON products.tenant_id = tenants.id AND products.is_active = 1
|
LEFT JOIN members ON members.tenant_id = tenants.id AND members.status = "active"
|
||||||
LEFT JOIN paper_sheets ON paper_sheets.tenant_id = tenants.id
|
LEFT JOIN products ON products.tenant_id = tenants.id AND products.is_active = 1
|
||||||
LEFT JOIN ledger_lines ON ledger_lines.tenant_id = tenants.id
|
LEFT JOIN paper_sheets ON paper_sheets.tenant_id = tenants.id
|
||||||
WHERE tenants.id = :tenant_id
|
LEFT JOIN ledger_lines ON ledger_lines.tenant_id = tenants.id
|
||||||
GROUP BY tenants.id'
|
WHERE tenants.id = :tenant_id
|
||||||
);
|
GROUP BY tenants.id'
|
||||||
$summaryStatement->execute(['tenant_id' => $tenant['id']]);
|
);
|
||||||
$summary = $summaryStatement->fetch() ?: [
|
$summaryStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
'member_count' => 0,
|
$summary = $summaryStatement->fetch() ?: [
|
||||||
'product_count' => 0,
|
'member_count' => 0,
|
||||||
'paper_sheet_count' => 0,
|
'product_count' => 0,
|
||||||
'open_balance_cents' => 0,
|
'paper_sheet_count' => 0,
|
||||||
];
|
'open_balance_cents' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
$eventsStatement = $pdo->prepare(
|
$eventsStatement = $pdo->prepare(
|
||||||
'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name
|
'SELECT c.*,
|
||||||
FROM consumption_events c
|
m.display_name AS member_name,
|
||||||
INNER JOIN members m ON m.id = c.member_id
|
p.name AS product_name,
|
||||||
INNER JOIN products p ON p.id = c.product_id
|
s.name AS source_name,
|
||||||
INNER JOIN capture_sources s ON s.id = c.source_id
|
CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
|
||||||
WHERE c.tenant_id = :tenant_id
|
FROM consumption_events c
|
||||||
ORDER BY c.recorded_at DESC
|
INNER JOIN members m ON m.id = c.member_id
|
||||||
LIMIT 8'
|
INNER JOIN products p ON p.id = c.product_id
|
||||||
);
|
INNER JOIN capture_sources s ON s.id = c.source_id
|
||||||
$eventsStatement->execute(['tenant_id' => $tenant['id']]);
|
LEFT JOIN ledger_entries le
|
||||||
|
ON le.tenant_id = c.tenant_id
|
||||||
|
AND le.reference_type = "consumption_event"
|
||||||
|
AND le.reference_id = c.id
|
||||||
|
AND le.type = "consumption"
|
||||||
|
LEFT JOIN ledger_entries reversal
|
||||||
|
ON reversal.tenant_id = le.tenant_id
|
||||||
|
AND reversal.reversal_of_entry_id = le.id
|
||||||
|
WHERE c.tenant_id = :tenant_id
|
||||||
|
ORDER BY c.recorded_at DESC
|
||||||
|
LIMIT 8'
|
||||||
|
);
|
||||||
|
$eventsStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
|
||||||
$balancesStatement = $pdo->prepare(
|
$balancesStatement = $pdo->prepare(
|
||||||
'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents
|
'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents
|
||||||
FROM members m
|
FROM members m
|
||||||
LEFT JOIN ledger_lines ll
|
LEFT JOIN ledger_lines ll
|
||||||
ON ll.member_id = m.id
|
ON ll.member_id = m.id
|
||||||
AND ll.tenant_id = m.tenant_id
|
AND ll.tenant_id = m.tenant_id
|
||||||
AND ll.account_code = "member_balance"
|
AND ll.account_code = "member_balance"
|
||||||
WHERE m.tenant_id = :tenant_id
|
WHERE m.tenant_id = :tenant_id
|
||||||
GROUP BY m.id
|
GROUP BY m.id
|
||||||
ORDER BY balance_cents DESC, m.display_name ASC
|
ORDER BY balance_cents DESC, m.display_name ASC
|
||||||
LIMIT 8'
|
LIMIT 8'
|
||||||
);
|
);
|
||||||
$balancesStatement->execute(['tenant_id' => $tenant['id']]);
|
$balancesStatement->execute(['tenant_id' => $tenant['id']]);
|
||||||
|
} else {
|
||||||
|
if ($currentMember === null) {
|
||||||
|
throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaryStatement = $pdo->prepare(
|
||||||
|
'SELECT
|
||||||
|
1 AS member_count,
|
||||||
|
(SELECT COUNT(*) FROM products WHERE tenant_id = :tenant_id_products AND is_active = 1) AS product_count,
|
||||||
|
0 AS paper_sheet_count,
|
||||||
|
COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0) AS open_balance_cents
|
||||||
|
FROM members m
|
||||||
|
LEFT JOIN ledger_lines ll
|
||||||
|
ON ll.member_id = m.id
|
||||||
|
AND ll.tenant_id = m.tenant_id
|
||||||
|
WHERE m.tenant_id = :tenant_id
|
||||||
|
AND m.id = :member_id
|
||||||
|
GROUP BY m.id'
|
||||||
|
);
|
||||||
|
$summaryStatement->execute([
|
||||||
|
'tenant_id_products' => $tenant['id'],
|
||||||
|
'tenant_id' => $tenant['id'],
|
||||||
|
'member_id' => $currentMember['id'],
|
||||||
|
]);
|
||||||
|
$summary = $summaryStatement->fetch() ?: [
|
||||||
|
'member_count' => 1,
|
||||||
|
'product_count' => 0,
|
||||||
|
'paper_sheet_count' => 0,
|
||||||
|
'open_balance_cents' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$eventsStatement = $pdo->prepare(
|
||||||
|
'SELECT c.*,
|
||||||
|
m.display_name AS member_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
s.name AS source_name,
|
||||||
|
CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
|
||||||
|
FROM consumption_events c
|
||||||
|
INNER JOIN members m ON m.id = c.member_id
|
||||||
|
INNER JOIN products p ON p.id = c.product_id
|
||||||
|
INNER JOIN capture_sources s ON s.id = c.source_id
|
||||||
|
LEFT JOIN ledger_entries le
|
||||||
|
ON le.tenant_id = c.tenant_id
|
||||||
|
AND le.reference_type = "consumption_event"
|
||||||
|
AND le.reference_id = c.id
|
||||||
|
AND le.type = "consumption"
|
||||||
|
LEFT JOIN ledger_entries reversal
|
||||||
|
ON reversal.tenant_id = le.tenant_id
|
||||||
|
AND reversal.reversal_of_entry_id = le.id
|
||||||
|
WHERE c.tenant_id = :tenant_id
|
||||||
|
AND c.member_id = :member_id
|
||||||
|
ORDER BY c.recorded_at DESC
|
||||||
|
LIMIT 8'
|
||||||
|
);
|
||||||
|
$eventsStatement->execute([
|
||||||
|
'tenant_id' => $tenant['id'],
|
||||||
|
'member_id' => $currentMember['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$balancesStatement = $pdo->prepare(
|
||||||
|
'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents
|
||||||
|
FROM members m
|
||||||
|
LEFT JOIN ledger_lines ll
|
||||||
|
ON ll.member_id = m.id
|
||||||
|
AND ll.tenant_id = m.tenant_id
|
||||||
|
AND ll.account_code = "member_balance"
|
||||||
|
WHERE m.tenant_id = :tenant_id
|
||||||
|
AND m.id = :member_id
|
||||||
|
GROUP BY m.id'
|
||||||
|
);
|
||||||
|
$balancesStatement->execute([
|
||||||
|
'tenant_id' => $tenant['id'],
|
||||||
|
'member_id' => $currentMember['id'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->view->render('tenant/dashboard', [
|
$this->view->render('tenant/dashboard', [
|
||||||
'title' => 'Dashboard',
|
'title' => 'Dashboard',
|
||||||
@@ -127,6 +239,8 @@ final class TenantController
|
|||||||
'summary' => $summary,
|
'summary' => $summary,
|
||||||
'latestEvents' => $eventsStatement->fetchAll(),
|
'latestEvents' => $eventsStatement->fetchAll(),
|
||||||
'memberBalances' => $balancesStatement->fetchAll(),
|
'memberBalances' => $balancesStatement->fetchAll(),
|
||||||
|
'isManager' => $isManager,
|
||||||
|
'currentMember' => $currentMember,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +262,10 @@ final class TenantController
|
|||||||
throw new RuntimeException('Bitte einen Anzeigenamen angeben.');
|
throw new RuntimeException('Bitte einen Anzeigenamen angeben.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($accessPin !== '' && mb_strlen($accessPin) < 4) {
|
||||||
|
throw new RuntimeException('Die PIN muss mindestens 4 Zeichen haben.');
|
||||||
|
}
|
||||||
|
|
||||||
$userId = null;
|
$userId = null;
|
||||||
|
|
||||||
if ($email !== '') {
|
if ($email !== '') {
|
||||||
@@ -159,6 +277,13 @@ final class TenantController
|
|||||||
throw new RuntimeException('Fuer Login-faehige Mitglieder ist ein Passwort mit mindestens 12 Zeichen noetig.');
|
throw new RuntimeException('Fuer Login-faehige Mitglieder ist ein Passwort mit mindestens 12 Zeichen noetig.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$existingUserStatement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||||
|
$existingUserStatement->execute(['email' => mb_strtolower($email)]);
|
||||||
|
|
||||||
|
if ($existingUserStatement->fetchColumn()) {
|
||||||
|
throw new RuntimeException('Die E-Mail-Adresse ist bereits im System vorhanden.');
|
||||||
|
}
|
||||||
|
|
||||||
$userInsert = $pdo->prepare(
|
$userInsert = $pdo->prepare(
|
||||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||||
VALUES (:full_name, :email, :password_hash, "user")'
|
VALUES (:full_name, :email, :password_hash, "user")'
|
||||||
@@ -189,7 +314,7 @@ final class TenantController
|
|||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'display_name' => $displayName,
|
'display_name' => $displayName,
|
||||||
'email' => $email !== '' ? mb_strtolower($email) : null,
|
'email' => $email !== '' ? mb_strtolower($email) : null,
|
||||||
'access_pin' => $accessPin !== '' ? $accessPin : null,
|
'access_pin' => $accessPin !== '' ? secure_password_hash($accessPin) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
(new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'member.created', 'member', (int) $pdo->lastInsertId(), 'success');
|
(new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'member.created', 'member', (int) $pdo->lastInsertId(), 'success');
|
||||||
@@ -291,23 +416,44 @@ final class TenantController
|
|||||||
{
|
{
|
||||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
||||||
$pdo = $this->database->pdo();
|
$pdo = $this->database->pdo();
|
||||||
|
$isManager = $this->isTenantManager($authState);
|
||||||
|
$currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState);
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$this->assertCsrf();
|
$this->assertCsrf();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$service = new LedgerService($pdo, new AuditService($pdo));
|
$service = new LedgerService($pdo, new AuditService($pdo));
|
||||||
$service->createConsumption(
|
$action = (string) ($_POST['action'] ?? 'create_booking');
|
||||||
(int) $tenant['id'],
|
|
||||||
(int) ($_POST['member_id'] ?? 0),
|
if ($action === 'reverse_booking') {
|
||||||
(int) ($_POST['product_id'] ?? 0),
|
if (!$isManager) {
|
||||||
(string) ($_POST['source_code'] ?? 'digital_self'),
|
throw new RuntimeException('Nur Admins duerfen Buchungen stornieren.');
|
||||||
normalize_datetime_input((string) ($_POST['effective_at'] ?? '')),
|
}
|
||||||
(int) $authState['user_id'],
|
|
||||||
(float) ($_POST['quantity'] ?? 1),
|
$service->reverseLedgerEntry(
|
||||||
trim((string) ($_POST['notes'] ?? ''))
|
(int) $tenant['id'],
|
||||||
);
|
(int) ($_POST['ledger_entry_id'] ?? 0),
|
||||||
$this->session->flash('success', 'Die Buchung wurde gespeichert.');
|
(int) $authState['user_id'],
|
||||||
|
trim((string) ($_POST['reason'] ?? '')) ?: null
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Die Buchung wurde storniert.');
|
||||||
|
} else {
|
||||||
|
$memberId = $this->selectedMemberIdForBooking((int) $tenant['id'], $authState, (int) ($_POST['member_id'] ?? 0), $currentMember);
|
||||||
|
$sourceCode = $isManager ? (string) ($_POST['source_code'] ?? 'digital_self') : 'digital_self';
|
||||||
|
|
||||||
|
$service->createConsumption(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
$memberId,
|
||||||
|
(int) ($_POST['product_id'] ?? 0),
|
||||||
|
$sourceCode,
|
||||||
|
normalize_datetime_input((string) ($_POST['effective_at'] ?? '')),
|
||||||
|
(int) $authState['user_id'],
|
||||||
|
(float) ($_POST['quantity'] ?? 1),
|
||||||
|
trim((string) ($_POST['notes'] ?? ''))
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Die Buchung wurde gespeichert.');
|
||||||
|
}
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
remember_old_input($_POST);
|
remember_old_input($_POST);
|
||||||
$this->session->flash('error', $throwable->getMessage());
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
@@ -316,20 +462,46 @@ final class TenantController
|
|||||||
Response::redirect(tenant_url($tenantSlug, 'bookings'));
|
Response::redirect(tenant_url($tenantSlug, 'bookings'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$members = $this->membersForTenant((int) $tenant['id']);
|
$members = $isManager
|
||||||
|
? $this->membersForTenant((int) $tenant['id'])
|
||||||
|
: ($currentMember !== null ? [['id' => $currentMember['id'], 'display_name' => $currentMember['display_name']]] : []);
|
||||||
$products = $this->productsForTenant((int) $tenant['id']);
|
$products = $this->productsForTenant((int) $tenant['id']);
|
||||||
|
|
||||||
$bookingsStatement = $pdo->prepare(
|
$sql = 'SELECT c.*,
|
||||||
'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name
|
m.display_name AS member_name,
|
||||||
FROM consumption_events c
|
p.name AS product_name,
|
||||||
INNER JOIN members m ON m.id = c.member_id
|
s.name AS source_name,
|
||||||
INNER JOIN products p ON p.id = c.product_id
|
le.id AS ledger_entry_id,
|
||||||
INNER JOIN capture_sources s ON s.id = c.source_id
|
CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
|
||||||
WHERE c.tenant_id = :tenant_id
|
FROM consumption_events c
|
||||||
ORDER BY c.recorded_at DESC
|
INNER JOIN members m ON m.id = c.member_id
|
||||||
LIMIT 30'
|
INNER JOIN products p ON p.id = c.product_id
|
||||||
);
|
INNER JOIN capture_sources s ON s.id = c.source_id
|
||||||
$bookingsStatement->execute(['tenant_id' => $tenant['id']]);
|
LEFT JOIN ledger_entries le
|
||||||
|
ON le.tenant_id = c.tenant_id
|
||||||
|
AND le.reference_type = "consumption_event"
|
||||||
|
AND le.reference_id = c.id
|
||||||
|
AND le.type = "consumption"
|
||||||
|
LEFT JOIN ledger_entries reversal
|
||||||
|
ON reversal.tenant_id = le.tenant_id
|
||||||
|
AND reversal.reversal_of_entry_id = le.id
|
||||||
|
WHERE c.tenant_id = :tenant_id';
|
||||||
|
|
||||||
|
$params = ['tenant_id' => $tenant['id']];
|
||||||
|
|
||||||
|
if (!$isManager) {
|
||||||
|
if ($currentMember === null) {
|
||||||
|
throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' AND c.member_id = :member_id';
|
||||||
|
$params['member_id'] = $currentMember['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY c.recorded_at DESC LIMIT 30';
|
||||||
|
|
||||||
|
$bookingsStatement = $pdo->prepare($sql);
|
||||||
|
$bookingsStatement->execute($params);
|
||||||
|
|
||||||
$this->view->render('tenant/bookings', [
|
$this->view->render('tenant/bookings', [
|
||||||
'title' => 'Digitale Buchungen',
|
'title' => 'Digitale Buchungen',
|
||||||
@@ -338,9 +510,80 @@ final class TenantController
|
|||||||
'members' => $members,
|
'members' => $members,
|
||||||
'products' => $products,
|
'products' => $products,
|
||||||
'bookings' => $bookingsStatement->fetchAll(),
|
'bookings' => $bookingsStatement->fetchAll(),
|
||||||
|
'isManager' => $isManager,
|
||||||
|
'currentMember' => $currentMember,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function exportBalances(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||||
|
|
||||||
|
(new AuditService($this->database->pdo()))->log(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
(int) $authState['user_id'],
|
||||||
|
'export.member_balances',
|
||||||
|
'tenant',
|
||||||
|
(int) $tenant['id'],
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
|
||||||
|
(new ExportService($this->database->pdo()))->streamMemberBalancesCsv(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
$tenant['slug'] . '-salden-' . gmdate('Ymd-His') . '.csv'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportBookings(string $tenantSlug): void
|
||||||
|
{
|
||||||
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
||||||
|
$isManager = $this->isTenantManager($authState);
|
||||||
|
$currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState);
|
||||||
|
$memberId = null;
|
||||||
|
|
||||||
|
if ($isManager) {
|
||||||
|
$requestedMemberId = (int) ($_GET['member_id'] ?? 0);
|
||||||
|
|
||||||
|
if ($requestedMemberId > 0) {
|
||||||
|
$memberId = $this->assertMemberIdBelongsToTenant((int) $tenant['id'], $requestedMemberId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($currentMember === null) {
|
||||||
|
throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$memberId = (int) $currentMember['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
(new AuditService($this->database->pdo()))->log(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
(int) $authState['user_id'],
|
||||||
|
'export.bookings',
|
||||||
|
'tenant',
|
||||||
|
(int) $tenant['id'],
|
||||||
|
'success',
|
||||||
|
[
|
||||||
|
'member_id' => $memberId,
|
||||||
|
'from' => trim((string) ($_GET['from'] ?? '')) ?: null,
|
||||||
|
'until' => trim((string) ($_GET['until'] ?? '')) ?: null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$filename = $tenant['slug'] . '-buchungen-' . gmdate('Ymd-His') . '.csv';
|
||||||
|
|
||||||
|
if ($memberId !== null) {
|
||||||
|
$filename = $tenant['slug'] . '-mitglied-' . $memberId . '-buchungen-' . gmdate('Ymd-His') . '.csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
(new ExportService($this->database->pdo()))->streamBookingsCsv(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
$memberId,
|
||||||
|
trim((string) ($_GET['from'] ?? '')) ?: null,
|
||||||
|
trim((string) ($_GET['until'] ?? '')) ?: null,
|
||||||
|
$filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function payments(string $tenantSlug): void
|
public function payments(string $tenantSlug): void
|
||||||
{
|
{
|
||||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||||
@@ -351,15 +594,27 @@ final class TenantController
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$service = new LedgerService($pdo, new AuditService($pdo));
|
$service = new LedgerService($pdo, new AuditService($pdo));
|
||||||
$service->createPayment(
|
$action = (string) ($_POST['action'] ?? 'create_payment');
|
||||||
(int) $tenant['id'],
|
|
||||||
(int) ($_POST['member_id'] ?? 0),
|
if ($action === 'reverse_payment') {
|
||||||
(int) round(((float) ($_POST['amount_eur'] ?? 0)) * 100),
|
$service->reverseLedgerEntry(
|
||||||
normalize_datetime_input((string) ($_POST['occurred_at'] ?? '')),
|
(int) $tenant['id'],
|
||||||
(int) $authState['user_id'],
|
(int) ($_POST['ledger_entry_id'] ?? 0),
|
||||||
trim((string) ($_POST['notes'] ?? ''))
|
(int) $authState['user_id'],
|
||||||
);
|
trim((string) ($_POST['reason'] ?? '')) ?: null
|
||||||
$this->session->flash('success', 'Die Einzahlung wurde gebucht.');
|
);
|
||||||
|
$this->session->flash('success', 'Die Einzahlung wurde storniert.');
|
||||||
|
} else {
|
||||||
|
$service->createPayment(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
(int) ($_POST['member_id'] ?? 0),
|
||||||
|
(int) round(((float) ($_POST['amount_eur'] ?? 0)) * 100),
|
||||||
|
normalize_datetime_input((string) ($_POST['occurred_at'] ?? '')),
|
||||||
|
(int) $authState['user_id'],
|
||||||
|
trim((string) ($_POST['notes'] ?? ''))
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Die Einzahlung wurde gebucht.');
|
||||||
|
}
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
remember_old_input($_POST);
|
remember_old_input($_POST);
|
||||||
$this->session->flash('error', $throwable->getMessage());
|
$this->session->flash('error', $throwable->getMessage());
|
||||||
@@ -372,10 +627,14 @@ final class TenantController
|
|||||||
$paymentsStatement = $pdo->prepare(
|
$paymentsStatement = $pdo->prepare(
|
||||||
'SELECT le.*,
|
'SELECT le.*,
|
||||||
m.display_name AS member_name,
|
m.display_name AS member_name,
|
||||||
ABS(COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0)) AS amount_cents
|
ABS(COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0)) AS amount_cents,
|
||||||
|
CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
|
||||||
FROM ledger_entries le
|
FROM ledger_entries le
|
||||||
LEFT JOIN members m ON m.id = le.member_id
|
LEFT JOIN members m ON m.id = le.member_id
|
||||||
LEFT JOIN ledger_lines ll ON ll.ledger_entry_id = le.id
|
LEFT JOIN ledger_lines ll ON ll.ledger_entry_id = le.id
|
||||||
|
LEFT JOIN ledger_entries reversal
|
||||||
|
ON reversal.tenant_id = le.tenant_id
|
||||||
|
AND reversal.reversal_of_entry_id = le.id
|
||||||
WHERE le.tenant_id = :tenant_id
|
WHERE le.tenant_id = :tenant_id
|
||||||
AND le.type = "payment"
|
AND le.type = "payment"
|
||||||
GROUP BY le.id
|
GROUP BY le.id
|
||||||
@@ -390,6 +649,7 @@ final class TenantController
|
|||||||
'authState' => $authState,
|
'authState' => $authState,
|
||||||
'members' => $members,
|
'members' => $members,
|
||||||
'payments' => $paymentsStatement->fetchAll(),
|
'payments' => $paymentsStatement->fetchAll(),
|
||||||
|
'isManager' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +657,6 @@ final class TenantController
|
|||||||
{
|
{
|
||||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
||||||
$pdo = $this->database->pdo();
|
$pdo = $this->database->pdo();
|
||||||
|
|
||||||
$service = new LedgerService($pdo, new AuditService($pdo));
|
$service = new LedgerService($pdo, new AuditService($pdo));
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
@@ -432,6 +691,14 @@ final class TenantController
|
|||||||
} elseif ($action === 'post_sheet') {
|
} elseif ($action === 'post_sheet') {
|
||||||
$service->postPaperSheet((int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id']);
|
$service->postPaperSheet((int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id']);
|
||||||
$this->session->flash('success', 'Die Papierliste wurde ins Ledger uebernommen.');
|
$this->session->flash('success', 'Die Papierliste wurde ins Ledger uebernommen.');
|
||||||
|
} elseif ($action === 'reverse_sheet') {
|
||||||
|
$service->reversePaperSheet(
|
||||||
|
(int) $tenant['id'],
|
||||||
|
(int) ($_POST['paper_sheet_id'] ?? 0),
|
||||||
|
(int) $authState['user_id'],
|
||||||
|
trim((string) ($_POST['reason'] ?? '')) ?: null
|
||||||
|
);
|
||||||
|
$this->session->flash('success', 'Die verbuchte Papierliste wurde storniert.');
|
||||||
}
|
}
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
remember_old_input($_POST);
|
remember_old_input($_POST);
|
||||||
@@ -444,9 +711,21 @@ final class TenantController
|
|||||||
$members = $this->membersForTenant((int) $tenant['id']);
|
$members = $this->membersForTenant((int) $tenant['id']);
|
||||||
$products = $this->productsForTenant((int) $tenant['id']);
|
$products = $this->productsForTenant((int) $tenant['id']);
|
||||||
$sheetsStatement = $pdo->prepare(
|
$sheetsStatement = $pdo->prepare(
|
||||||
'SELECT ps.*, COUNT(psl.id) AS line_count
|
'SELECT ps.*,
|
||||||
|
COUNT(psl.id) AS line_count,
|
||||||
|
COUNT(DISTINCT le.id) AS posted_entry_count,
|
||||||
|
COUNT(DISTINCT reversal.id) AS reversed_entry_count
|
||||||
FROM paper_sheets ps
|
FROM paper_sheets ps
|
||||||
LEFT JOIN paper_sheet_lines psl ON psl.paper_sheet_id = ps.id
|
LEFT JOIN paper_sheet_lines psl ON psl.paper_sheet_id = ps.id
|
||||||
|
LEFT JOIN consumption_events ce ON ce.paper_sheet_line_id = psl.id
|
||||||
|
LEFT JOIN ledger_entries le
|
||||||
|
ON le.reference_type = "consumption_event"
|
||||||
|
AND le.reference_id = ce.id
|
||||||
|
AND le.tenant_id = ps.tenant_id
|
||||||
|
AND le.type = "consumption"
|
||||||
|
LEFT JOIN ledger_entries reversal
|
||||||
|
ON reversal.reversal_of_entry_id = le.id
|
||||||
|
AND reversal.tenant_id = le.tenant_id
|
||||||
WHERE ps.tenant_id = :tenant_id
|
WHERE ps.tenant_id = :tenant_id
|
||||||
GROUP BY ps.id
|
GROUP BY ps.id
|
||||||
ORDER BY ps.created_at DESC'
|
ORDER BY ps.created_at DESC'
|
||||||
@@ -472,6 +751,7 @@ final class TenantController
|
|||||||
'products' => $products,
|
'products' => $products,
|
||||||
'sheets' => $sheets,
|
'sheets' => $sheets,
|
||||||
'sheetLines' => $linesStatement->fetchAll(),
|
'sheetLines' => $linesStatement->fetchAll(),
|
||||||
|
'isManager' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,11 +822,18 @@ final class TenantController
|
|||||||
'devices' => $devices->fetchAll(),
|
'devices' => $devices->fetchAll(),
|
||||||
'tags' => $tags->fetchAll(),
|
'tags' => $tags->fetchAll(),
|
||||||
'events' => $events->fetchAll(),
|
'events' => $events->fetchAll(),
|
||||||
|
'isManager' => true,
|
||||||
|
'rfidIngestEnabled' => (bool) ($this->config['rfid_ingest_enabled'] ?? false),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rfidIngest(): void
|
public function rfidIngest(): void
|
||||||
{
|
{
|
||||||
|
if (!($this->config['rfid_ingest_enabled'] ?? false)) {
|
||||||
|
Response::notFound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
|
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
|
||||||
|
|
||||||
if (!is_array($payload)) {
|
if (!is_array($payload)) {
|
||||||
@@ -596,7 +883,20 @@ final class TenantController
|
|||||||
Response::redirect(tenant_url($tenantSlug));
|
Response::redirect(tenant_url($tenantSlug));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$tenant, $auth->user()];
|
$authState = $auth->user();
|
||||||
|
|
||||||
|
if (!is_array($authState)) {
|
||||||
|
$this->session->flash('error', 'Bitte zuerst anmelden.');
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) {
|
||||||
|
$auth->logout();
|
||||||
|
$this->session->flash('error', 'Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.');
|
||||||
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$tenant, $authState];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function membersForTenant(int $tenantId): array
|
private function membersForTenant(int $tenantId): array
|
||||||
@@ -627,10 +927,147 @@ final class TenantController
|
|||||||
return $statement->fetchAll();
|
return $statement->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function memberRecordForAuthState(int $tenantId, array $authState): ?array
|
||||||
|
{
|
||||||
|
$memberId = $authState['member_id'] ?? null;
|
||||||
|
|
||||||
|
if (!is_int($memberId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->database->pdo()->prepare(
|
||||||
|
'SELECT id, display_name, email
|
||||||
|
FROM members
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND id = :id
|
||||||
|
AND status = "active"
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'id' => $memberId,
|
||||||
|
]);
|
||||||
|
$member = $statement->fetch();
|
||||||
|
|
||||||
|
return $member ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isTenantManager(array $authState): bool
|
||||||
|
{
|
||||||
|
$role = $authState['tenant_role'] ?? null;
|
||||||
|
|
||||||
|
return is_string($role) && in_array($role, ['owner', 'admin'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selectedMemberIdForBooking(
|
||||||
|
int $tenantId,
|
||||||
|
array $authState,
|
||||||
|
int $postedMemberId,
|
||||||
|
?array $currentMember
|
||||||
|
): int {
|
||||||
|
if ($this->isTenantManager($authState)) {
|
||||||
|
if ($postedMemberId <= 0) {
|
||||||
|
throw new RuntimeException('Bitte ein Mitglied fuer die Buchung auswaehlen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->assertMemberIdBelongsToTenant($tenantId, $postedMemberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentMember === null) {
|
||||||
|
throw new RuntimeException('Fuer dieses Konto ist kein buchungsfaehiges Mitglied hinterlegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($postedMemberId > 0 && $postedMemberId !== (int) $currentMember['id']) {
|
||||||
|
throw new RuntimeException('Du darfst nur fuer dein eigenes Mitglied buchen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $currentMember['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertMemberIdBelongsToTenant(int $tenantId, int $memberId): int
|
||||||
|
{
|
||||||
|
$statement = $this->database->pdo()->prepare(
|
||||||
|
'SELECT id
|
||||||
|
FROM members
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND id = :id
|
||||||
|
AND status = "active"
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'id' => $memberId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$statement->fetchColumn()) {
|
||||||
|
throw new RuntimeException('Das angegebene Mitglied gehoert nicht zu diesem Mandanten.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $memberId;
|
||||||
|
}
|
||||||
|
|
||||||
private function assertCsrf(): void
|
private function assertCsrf(): void
|
||||||
{
|
{
|
||||||
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
|
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
|
||||||
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden.');
|
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function selfServiceAllowed(int $tenantId): bool
|
||||||
|
{
|
||||||
|
$statement = $this->database->pdo()->prepare(
|
||||||
|
'SELECT allow_self_service
|
||||||
|
FROM tenant_settings
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['tenant_id' => $tenantId]);
|
||||||
|
$value = $statement->fetchColumn();
|
||||||
|
|
||||||
|
return $value === false ? true : (int) $value === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rateLimiter(\PDO $pdo): RateLimiterService
|
||||||
|
{
|
||||||
|
return new RateLimiterService($pdo, $this->config['rate_limits'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildRateLimitScopes(string $tenantSlug, string $email): array
|
||||||
|
{
|
||||||
|
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
|
||||||
|
$scopes = ['tenant-login|ip|' . $tenantSlug . '|' . $ip];
|
||||||
|
|
||||||
|
if (trim($email) !== '') {
|
||||||
|
$scopes[] = 'tenant-login|subject|' . $tenantSlug . '|' . $email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureRateLimitAllowed(RateLimiterService $rateLimiter, array $scopes): void
|
||||||
|
{
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
$state = $rateLimiter->inspect('login', $scope);
|
||||||
|
|
||||||
|
if (!$state['allowed']) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Zu viele Login-Versuche. Bitte in etwa ' . (int) $state['retry_after_seconds'] . ' Sekunden erneut versuchen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hitRateLimitScopes(RateLimiterService $rateLimiter, array $scopes): void
|
||||||
|
{
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
$rateLimiter->hit('login', $scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearRateLimitScopes(RateLimiterService $rateLimiter, array $scopes): void
|
||||||
|
{
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
$rateLimiter->clear('login', $scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-1
@@ -28,6 +28,8 @@ final class Auth
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->rehashPasswordIfNeeded((int) $user['id'], (string) $user['password_hash'], $password);
|
||||||
|
|
||||||
$this->session->regenerate();
|
$this->session->regenerate();
|
||||||
$this->session->put('auth', [
|
$this->session->put('auth', [
|
||||||
'user_id' => (int) $user['id'],
|
'user_id' => (int) $user['id'],
|
||||||
@@ -44,10 +46,15 @@ final class Auth
|
|||||||
u.password_hash,
|
u.password_hash,
|
||||||
m.tenant_id,
|
m.tenant_id,
|
||||||
m.role,
|
m.role,
|
||||||
t.slug AS tenant_slug
|
t.slug AS tenant_slug,
|
||||||
|
member.id AS member_id
|
||||||
FROM users u
|
FROM users u
|
||||||
INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active"
|
INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active"
|
||||||
INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active"
|
INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active"
|
||||||
|
LEFT JOIN members member
|
||||||
|
ON member.user_id = u.id
|
||||||
|
AND member.tenant_id = m.tenant_id
|
||||||
|
AND member.status = "active"
|
||||||
WHERE u.email = :email
|
WHERE u.email = :email
|
||||||
LIMIT 1'
|
LIMIT 1'
|
||||||
);
|
);
|
||||||
@@ -61,12 +68,15 @@ final class Auth
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->rehashPasswordIfNeeded((int) $membership['id'], (string) $membership['password_hash'], $password);
|
||||||
|
|
||||||
$this->session->regenerate();
|
$this->session->regenerate();
|
||||||
$this->session->put('auth', [
|
$this->session->put('auth', [
|
||||||
'user_id' => (int) $membership['id'],
|
'user_id' => (int) $membership['id'],
|
||||||
'tenant_id' => (int) $membership['tenant_id'],
|
'tenant_id' => (int) $membership['tenant_id'],
|
||||||
'tenant_slug' => $membership['tenant_slug'],
|
'tenant_slug' => $membership['tenant_slug'],
|
||||||
'tenant_role' => $membership['role'],
|
'tenant_role' => $membership['role'],
|
||||||
|
'member_id' => isset($membership['member_id']) ? (int) $membership['member_id'] : null,
|
||||||
'platform_role' => 'user',
|
'platform_role' => 'user',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -101,8 +111,37 @@ final class Auth
|
|||||||
return is_string($role) && in_array($role, $allowedRoles, true);
|
return is_string($role) && in_array($role, $allowedRoles, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tenantMemberId(): ?int
|
||||||
|
{
|
||||||
|
$memberId = $this->user()['member_id'] ?? null;
|
||||||
|
|
||||||
|
return is_int($memberId) ? $memberId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canManageTenant(): bool
|
||||||
|
{
|
||||||
|
$role = $this->user()['tenant_role'] ?? null;
|
||||||
|
|
||||||
|
return is_string($role) && in_array($role, ['owner', 'admin'], true);
|
||||||
|
}
|
||||||
|
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
$this->session->invalidate();
|
$this->session->invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function rehashPasswordIfNeeded(int $userId, string $currentHash, string $password): void
|
||||||
|
{
|
||||||
|
if (!password_needs_rehash($currentHash, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'UPDATE users SET password_hash = :password_hash WHERE id = :id'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'password_hash' => secure_password_hash($password),
|
||||||
|
'id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class ExportService
|
||||||
|
{
|
||||||
|
private const CSV_DELIMITER = ';';
|
||||||
|
private const FLUSH_EVERY_ROWS = 250;
|
||||||
|
|
||||||
|
public function __construct(private readonly PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function streamMemberBalancesCsv(int $tenantId, ?string $filename = null): void
|
||||||
|
{
|
||||||
|
$this->assertTenantId($tenantId);
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT
|
||||||
|
m.id AS member_id,
|
||||||
|
m.display_name,
|
||||||
|
m.email,
|
||||||
|
m.status,
|
||||||
|
m.created_at,
|
||||||
|
COALESCE(balance_summary.balance_cents, 0) AS balance_cents,
|
||||||
|
balance_summary.last_occurred_at
|
||||||
|
FROM members m
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
ll.member_id,
|
||||||
|
SUM(ll.amount_cents) AS balance_cents,
|
||||||
|
MAX(le.occurred_at) AS last_occurred_at
|
||||||
|
FROM ledger_lines ll
|
||||||
|
INNER JOIN ledger_entries le
|
||||||
|
ON le.id = ll.ledger_entry_id
|
||||||
|
AND le.tenant_id = ll.tenant_id
|
||||||
|
WHERE ll.tenant_id = :balance_tenant_id
|
||||||
|
AND ll.account_code = "member_balance"
|
||||||
|
AND ll.member_id IS NOT NULL
|
||||||
|
GROUP BY ll.member_id
|
||||||
|
) balance_summary
|
||||||
|
ON balance_summary.member_id = m.id
|
||||||
|
WHERE m.tenant_id = :tenant_id
|
||||||
|
ORDER BY m.display_name ASC, m.id ASC'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'balance_tenant_id' => $tenantId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$handle = $this->openCsvOutput($filename ?? $this->defaultFilename('member-balances', $tenantId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->writeCsvRow($handle, [
|
||||||
|
'mitglied_id',
|
||||||
|
'anzeigename',
|
||||||
|
'email',
|
||||||
|
'status',
|
||||||
|
'saldo_cents',
|
||||||
|
'saldo_eur',
|
||||||
|
'letzte_buchung_am',
|
||||||
|
'angelegt_am',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rowCount = 0;
|
||||||
|
|
||||||
|
while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
|
||||||
|
$balanceCents = (int) $row['balance_cents'];
|
||||||
|
|
||||||
|
$this->writeCsvRow($handle, [
|
||||||
|
(int) $row['member_id'],
|
||||||
|
(string) $row['display_name'],
|
||||||
|
$row['email'],
|
||||||
|
(string) $row['status'],
|
||||||
|
$balanceCents,
|
||||||
|
$this->formatMoneyFromCents($balanceCents),
|
||||||
|
$row['last_occurred_at'],
|
||||||
|
(string) $row['created_at'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rowCount++;
|
||||||
|
$this->flushIfNeeded($rowCount);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function streamBookingsCsv(
|
||||||
|
int $tenantId,
|
||||||
|
?int $memberId = null,
|
||||||
|
?string $from = null,
|
||||||
|
?string $until = null,
|
||||||
|
?string $filename = null
|
||||||
|
): void {
|
||||||
|
$this->assertTenantId($tenantId);
|
||||||
|
|
||||||
|
if ($memberId !== null && $memberId <= 0) {
|
||||||
|
throw new RuntimeException('Die Mitglieds-ID muss groesser als 0 sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $this->normalizeDateTimeFilter($from, false);
|
||||||
|
$until = $this->normalizeDateTimeFilter($until, true);
|
||||||
|
|
||||||
|
$sql = 'SELECT
|
||||||
|
le.id AS booking_id,
|
||||||
|
le.type,
|
||||||
|
le.reference_type,
|
||||||
|
le.reference_id,
|
||||||
|
le.description,
|
||||||
|
le.occurred_at,
|
||||||
|
le.created_at AS entry_created_at,
|
||||||
|
le.member_id,
|
||||||
|
m.display_name AS member_name,
|
||||||
|
s.code AS source_code,
|
||||||
|
s.name AS source_name,
|
||||||
|
COALESCE(line_totals.member_balance_cents, 0) AS member_balance_cents,
|
||||||
|
COALESCE(line_totals.cashbox_cents, 0) AS cashbox_cents,
|
||||||
|
COALESCE(line_totals.coffee_revenue_cents, 0) AS coffee_revenue_cents,
|
||||||
|
c.id AS consumption_event_id,
|
||||||
|
c.quantity,
|
||||||
|
c.unit_price_cents,
|
||||||
|
c.total_cents,
|
||||||
|
c.effective_at,
|
||||||
|
c.recorded_at,
|
||||||
|
c.source_reference,
|
||||||
|
c.notes,
|
||||||
|
c.paper_sheet_line_id,
|
||||||
|
p.id AS product_id,
|
||||||
|
p.name AS product_name
|
||||||
|
FROM ledger_entries le
|
||||||
|
LEFT JOIN members m
|
||||||
|
ON m.id = le.member_id
|
||||||
|
AND m.tenant_id = le.tenant_id
|
||||||
|
LEFT JOIN capture_sources s
|
||||||
|
ON s.id = le.source_id
|
||||||
|
AND s.tenant_id = le.tenant_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
ledger_entry_id,
|
||||||
|
SUM(CASE WHEN account_code = "member_balance" THEN amount_cents ELSE 0 END) AS member_balance_cents,
|
||||||
|
SUM(CASE WHEN account_code = "cashbox" THEN amount_cents ELSE 0 END) AS cashbox_cents,
|
||||||
|
SUM(CASE WHEN account_code = "coffee_revenue" THEN amount_cents ELSE 0 END) AS coffee_revenue_cents
|
||||||
|
FROM ledger_lines
|
||||||
|
WHERE tenant_id = :line_tenant_id
|
||||||
|
GROUP BY ledger_entry_id
|
||||||
|
) line_totals
|
||||||
|
ON line_totals.ledger_entry_id = le.id
|
||||||
|
LEFT JOIN consumption_events c
|
||||||
|
ON c.id = le.reference_id
|
||||||
|
AND c.tenant_id = le.tenant_id
|
||||||
|
AND le.reference_type = "consumption_event"
|
||||||
|
LEFT JOIN products p
|
||||||
|
ON p.id = c.product_id
|
||||||
|
AND p.tenant_id = c.tenant_id
|
||||||
|
WHERE le.tenant_id = :tenant_id';
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'line_tenant_id' => $tenantId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($memberId !== null) {
|
||||||
|
$sql .= ' AND le.member_id = :member_id';
|
||||||
|
$params['member_id'] = $memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($from !== null) {
|
||||||
|
$sql .= ' AND le.occurred_at >= :from_occurred_at';
|
||||||
|
$params['from_occurred_at'] = $from;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($until !== null) {
|
||||||
|
$sql .= ' AND le.occurred_at <= :until_occurred_at';
|
||||||
|
$params['until_occurred_at'] = $until;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY le.occurred_at DESC, le.id DESC';
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare($sql);
|
||||||
|
$statement->execute($params);
|
||||||
|
|
||||||
|
$handle = $this->openCsvOutput($filename ?? $this->defaultFilename('bookings', $tenantId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->writeCsvRow($handle, [
|
||||||
|
'buchung_id',
|
||||||
|
'typ',
|
||||||
|
'buchungsdatum',
|
||||||
|
'mitglied_id',
|
||||||
|
'mitglied_name',
|
||||||
|
'beschreibung',
|
||||||
|
'quellcode',
|
||||||
|
'quelle',
|
||||||
|
'referenz_typ',
|
||||||
|
'referenz_id',
|
||||||
|
'saldo_delta_cents',
|
||||||
|
'saldo_delta_eur',
|
||||||
|
'kasse_delta_cents',
|
||||||
|
'kasse_delta_eur',
|
||||||
|
'umsatz_delta_cents',
|
||||||
|
'umsatz_delta_eur',
|
||||||
|
'produkt_id',
|
||||||
|
'produkt_name',
|
||||||
|
'menge',
|
||||||
|
'einzelpreis_cents',
|
||||||
|
'einzelpreis_eur',
|
||||||
|
'gesamt_cents',
|
||||||
|
'gesamt_eur',
|
||||||
|
'notizen',
|
||||||
|
'wirksam_am',
|
||||||
|
'erfasst_am',
|
||||||
|
'quellreferenz',
|
||||||
|
'papierlisten_zeile_id',
|
||||||
|
'angelegt_am',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rowCount = 0;
|
||||||
|
|
||||||
|
while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
|
||||||
|
$memberBalanceCents = (int) $row['member_balance_cents'];
|
||||||
|
$cashboxCents = (int) $row['cashbox_cents'];
|
||||||
|
$coffeeRevenueCents = (int) $row['coffee_revenue_cents'];
|
||||||
|
$unitPriceCents = $row['unit_price_cents'] !== null ? (int) $row['unit_price_cents'] : null;
|
||||||
|
$totalCents = $row['total_cents'] !== null ? (int) $row['total_cents'] : null;
|
||||||
|
$recordedAt = $row['recorded_at'] ?? $row['entry_created_at'];
|
||||||
|
|
||||||
|
$this->writeCsvRow($handle, [
|
||||||
|
(int) $row['booking_id'],
|
||||||
|
(string) $row['type'],
|
||||||
|
(string) $row['occurred_at'],
|
||||||
|
$row['member_id'] !== null ? (int) $row['member_id'] : '',
|
||||||
|
$row['member_name'],
|
||||||
|
(string) $row['description'],
|
||||||
|
$row['source_code'],
|
||||||
|
$row['source_name'],
|
||||||
|
$row['reference_type'],
|
||||||
|
$row['reference_id'],
|
||||||
|
$memberBalanceCents,
|
||||||
|
$this->formatMoneyFromCents($memberBalanceCents),
|
||||||
|
$cashboxCents,
|
||||||
|
$this->formatMoneyFromCents($cashboxCents),
|
||||||
|
$coffeeRevenueCents,
|
||||||
|
$this->formatMoneyFromCents($coffeeRevenueCents),
|
||||||
|
$row['product_id'] !== null ? (int) $row['product_id'] : '',
|
||||||
|
$row['product_name'],
|
||||||
|
$this->formatQuantity($row['quantity']),
|
||||||
|
$unitPriceCents ?? '',
|
||||||
|
$unitPriceCents !== null ? $this->formatMoneyFromCents($unitPriceCents) : '',
|
||||||
|
$totalCents ?? '',
|
||||||
|
$totalCents !== null ? $this->formatMoneyFromCents($totalCents) : '',
|
||||||
|
$row['notes'],
|
||||||
|
$row['effective_at'] ?? $row['occurred_at'],
|
||||||
|
$recordedAt,
|
||||||
|
$row['source_reference'],
|
||||||
|
$row['paper_sheet_line_id'],
|
||||||
|
(string) $row['entry_created_at'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rowCount++;
|
||||||
|
$this->flushIfNeeded($rowCount);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertTenantId(int $tenantId): void
|
||||||
|
{
|
||||||
|
if ($tenantId <= 0) {
|
||||||
|
throw new RuntimeException('Die Tenant-ID muss groesser als 0 sein.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultFilename(string $prefix, int $tenantId): string
|
||||||
|
{
|
||||||
|
return sprintf('%s-tenant-%d-%s.csv', $prefix, $tenantId, gmdate('Ymd-His'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function openCsvOutput(string $filename)
|
||||||
|
{
|
||||||
|
if (headers_sent($file, $line)) {
|
||||||
|
throw new RuntimeException(sprintf('CSV-Header koennen nicht mehr gesendet werden (%s:%d).', $file, $line));
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeFilename = $this->sanitizeFilename($filename);
|
||||||
|
|
||||||
|
header('Content-Type: text/csv; charset=UTF-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $safeFilename . '"; filename*=UTF-8\'\'' . rawurlencode($safeFilename));
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
header('Expires: 0');
|
||||||
|
header('X-Accel-Buffering: no');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
$handle = fopen('php://output', 'wb');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new RuntimeException('php://output konnte nicht geoeffnet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite($handle, "\xEF\xBB\xBF");
|
||||||
|
|
||||||
|
return $handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeFilename(string $filename): string
|
||||||
|
{
|
||||||
|
$sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '-', trim($filename)) ?? '';
|
||||||
|
$sanitized = trim($sanitized, '.-');
|
||||||
|
|
||||||
|
if ($sanitized === '') {
|
||||||
|
$sanitized = 'export';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!str_ends_with(strtolower($sanitized), '.csv')) {
|
||||||
|
$sanitized .= '.csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeCsvRow($handle, array $row): void
|
||||||
|
{
|
||||||
|
$values = array_map(function (mixed $value): string {
|
||||||
|
return $this->sanitizeCsvValue($value);
|
||||||
|
}, $row);
|
||||||
|
|
||||||
|
if (fputcsv($handle, $values, self::CSV_DELIMITER) === false) {
|
||||||
|
throw new RuntimeException('Die CSV-Zeile konnte nicht geschrieben werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeCsvValue(mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
$string = str_replace(["\r\n", "\r"], "\n", (string) $value);
|
||||||
|
|
||||||
|
// Prevent spreadsheet formula execution for user-controlled text cells.
|
||||||
|
if (
|
||||||
|
$string !== ''
|
||||||
|
&& in_array($string[0], ['=', '+', '-', '@', "\t"], true)
|
||||||
|
&& !is_numeric($string)
|
||||||
|
) {
|
||||||
|
return "'" . $string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $string;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatMoneyFromCents(int $amountCents): string
|
||||||
|
{
|
||||||
|
return number_format($amountCents / 100, 2, ',', '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatQuantity(mixed $quantity): string
|
||||||
|
{
|
||||||
|
if ($quantity === null || $quantity === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(rtrim(number_format((float) $quantity, 2, '.', ''), '0'), '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDateTimeFilter(?string $value, bool $endOfDay): ?string
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = str_replace('T', ' ', $value);
|
||||||
|
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
|
||||||
|
return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value) === 1) {
|
||||||
|
return $value . ':00';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flushIfNeeded(int $rowCount): void
|
||||||
|
{
|
||||||
|
if ($rowCount % self::FLUSH_EVERY_ROWS !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
ob_flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ final class LedgerService
|
|||||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertTenantMembership($tenantId, $recordedByUserId);
|
||||||
|
$this->assertTenantMember($tenantId, $memberId);
|
||||||
|
$this->assertTenantProduct($tenantId, $productId);
|
||||||
$source = $this->findSource($tenantId, $sourceCode);
|
$source = $this->findSource($tenantId, $sourceCode);
|
||||||
$priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt);
|
$priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt);
|
||||||
$totalCents = (int) round($priceCents * $quantity);
|
$totalCents = (int) round($priceCents * $quantity);
|
||||||
@@ -179,6 +182,8 @@ final class LedgerService
|
|||||||
throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.');
|
throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertTenantMembership($tenantId, $recordedByUserId);
|
||||||
|
$this->assertTenantMember($tenantId, $memberId);
|
||||||
$source = $this->findSource($tenantId, 'admin_backoffice');
|
$source = $this->findSource($tenantId, 'admin_backoffice');
|
||||||
|
|
||||||
$entryInsert = $this->pdo->prepare(
|
$entryInsert = $this->pdo->prepare(
|
||||||
@@ -270,6 +275,12 @@ final class LedgerService
|
|||||||
?string $notes,
|
?string $notes,
|
||||||
int $createdByUserId
|
int $createdByUserId
|
||||||
): int {
|
): int {
|
||||||
|
$this->assertTenantMembership($tenantId, $createdByUserId);
|
||||||
|
|
||||||
|
if (trim($title) === '') {
|
||||||
|
throw new RuntimeException('Bitte einen Titel fuer die Papierliste angeben.');
|
||||||
|
}
|
||||||
|
|
||||||
$statement = $this->pdo->prepare(
|
$statement = $this->pdo->prepare(
|
||||||
'INSERT INTO paper_sheets (
|
'INSERT INTO paper_sheets (
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -320,6 +331,10 @@ final class LedgerService
|
|||||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertTenantMembership($tenantId, $actorUserId);
|
||||||
|
$this->assertTenantMember($tenantId, $memberId);
|
||||||
|
$this->assertTenantProduct($tenantId, $productId);
|
||||||
|
|
||||||
$sheet = $this->pdo->prepare(
|
$sheet = $this->pdo->prepare(
|
||||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||||
);
|
);
|
||||||
@@ -375,6 +390,8 @@ final class LedgerService
|
|||||||
|
|
||||||
public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void
|
public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void
|
||||||
{
|
{
|
||||||
|
$this->assertTenantMembership($tenantId, $actorUserId);
|
||||||
|
|
||||||
$sheetStatement = $this->pdo->prepare(
|
$sheetStatement = $this->pdo->prepare(
|
||||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||||
);
|
);
|
||||||
@@ -432,6 +449,170 @@ final class LedgerService
|
|||||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success');
|
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reverseLedgerEntry(int $tenantId, int $ledgerEntryId, int $actorUserId, ?string $reason = null): int
|
||||||
|
{
|
||||||
|
$this->assertTenantMembership($tenantId, $actorUserId);
|
||||||
|
|
||||||
|
$entry = $this->ledgerEntry($tenantId, $ledgerEntryId);
|
||||||
|
|
||||||
|
if ($this->hasReversal($tenantId, $ledgerEntryId)) {
|
||||||
|
throw new RuntimeException('Dieser Vorgang wurde bereits storniert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$linesStatement = $this->pdo->prepare(
|
||||||
|
'SELECT member_id, account_code, amount_cents
|
||||||
|
FROM ledger_lines
|
||||||
|
WHERE tenant_id = :tenant_id AND ledger_entry_id = :ledger_entry_id
|
||||||
|
ORDER BY id ASC'
|
||||||
|
);
|
||||||
|
$linesStatement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'ledger_entry_id' => $ledgerEntryId,
|
||||||
|
]);
|
||||||
|
$lines = $linesStatement->fetchAll();
|
||||||
|
|
||||||
|
if ($lines === []) {
|
||||||
|
throw new RuntimeException('Es wurden keine Ledger-Zeilen fuer den Vorgang gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$description = 'Storno: ' . $entry['description'];
|
||||||
|
|
||||||
|
if ($reason !== null && trim($reason) !== '') {
|
||||||
|
$description .= ' (' . trim($reason) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$entryInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO ledger_entries (
|
||||||
|
tenant_id,
|
||||||
|
member_id,
|
||||||
|
source_id,
|
||||||
|
type,
|
||||||
|
reference_type,
|
||||||
|
reference_id,
|
||||||
|
description,
|
||||||
|
occurred_at,
|
||||||
|
created_by_user_id,
|
||||||
|
reversal_of_entry_id
|
||||||
|
) VALUES (
|
||||||
|
:tenant_id,
|
||||||
|
:member_id,
|
||||||
|
:source_id,
|
||||||
|
"correction",
|
||||||
|
"ledger_reversal",
|
||||||
|
:reference_id,
|
||||||
|
:description,
|
||||||
|
:occurred_at,
|
||||||
|
:created_by_user_id,
|
||||||
|
:reversal_of_entry_id
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$entryInsert->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => $entry['member_id'],
|
||||||
|
'source_id' => $entry['source_id'],
|
||||||
|
'reference_id' => $ledgerEntryId,
|
||||||
|
'description' => $description,
|
||||||
|
'occurred_at' => gmdate('Y-m-d H:i:s'),
|
||||||
|
'created_by_user_id' => $actorUserId,
|
||||||
|
'reversal_of_entry_id' => $ledgerEntryId,
|
||||||
|
]);
|
||||||
|
$reversalId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$lineInsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO ledger_lines (
|
||||||
|
ledger_entry_id,
|
||||||
|
tenant_id,
|
||||||
|
member_id,
|
||||||
|
account_code,
|
||||||
|
amount_cents
|
||||||
|
) VALUES (
|
||||||
|
:ledger_entry_id,
|
||||||
|
:tenant_id,
|
||||||
|
:member_id,
|
||||||
|
:account_code,
|
||||||
|
:amount_cents
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$lineInsert->execute([
|
||||||
|
'ledger_entry_id' => $reversalId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'member_id' => $line['member_id'] !== null ? (int) $line['member_id'] : null,
|
||||||
|
'account_code' => $line['account_code'],
|
||||||
|
'amount_cents' => 0 - (int) $line['amount_cents'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdo->commit();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $actorUserId, 'ledger_entry.reversed', 'ledger_entry', $reversalId, 'success', [
|
||||||
|
'reversal_of_entry_id' => $ledgerEntryId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $reversalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reversePaperSheet(int $tenantId, int $sheetId, int $actorUserId, ?string $reason = null): int
|
||||||
|
{
|
||||||
|
$this->assertTenantMembership($tenantId, $actorUserId);
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT DISTINCT le.id
|
||||||
|
FROM ledger_entries le
|
||||||
|
INNER JOIN consumption_events ce
|
||||||
|
ON ce.id = le.reference_id
|
||||||
|
AND le.reference_type = "consumption_event"
|
||||||
|
INNER JOIN paper_sheet_lines psl
|
||||||
|
ON psl.id = ce.paper_sheet_line_id
|
||||||
|
WHERE le.tenant_id = :tenant_id
|
||||||
|
AND psl.paper_sheet_id = :paper_sheet_id
|
||||||
|
ORDER BY le.id ASC'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'paper_sheet_id' => $sheetId,
|
||||||
|
]);
|
||||||
|
$entryIds = $statement->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
if ($entryIds === []) {
|
||||||
|
throw new RuntimeException('Zu dieser Papierliste wurden keine verbuchten Eintraege gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reversed = 0;
|
||||||
|
|
||||||
|
foreach ($entryIds as $entryId) {
|
||||||
|
if ($this->hasReversal($tenantId, (int) $entryId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reverseLedgerEntry(
|
||||||
|
$tenantId,
|
||||||
|
(int) $entryId,
|
||||||
|
$actorUserId,
|
||||||
|
$reason !== null && trim($reason) !== '' ? $reason : 'Papierliste #' . $sheetId
|
||||||
|
);
|
||||||
|
$reversed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reversed === 0) {
|
||||||
|
throw new RuntimeException('Diese Papierliste wurde bereits vollstaendig storniert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.reversed', 'paper_sheet', $sheetId, 'success', [
|
||||||
|
'reversed_entries' => $reversed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $reversed;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int
|
private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int
|
||||||
{
|
{
|
||||||
$statement = $this->pdo->prepare(
|
$statement = $this->pdo->prepare(
|
||||||
@@ -475,4 +656,93 @@ final class LedgerService
|
|||||||
|
|
||||||
return $source;
|
return $source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertTenantMember(int $tenantId, int $memberId): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id FROM members WHERE id = :id AND tenant_id = :tenant_id AND status = "active" LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'id' => $memberId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$statement->fetchColumn()) {
|
||||||
|
throw new RuntimeException('Das gewaehlte Mitglied gehoert nicht zu diesem Mandanten.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertTenantProduct(int $tenantId, int $productId): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id FROM products WHERE id = :id AND tenant_id = :tenant_id AND is_active = 1 LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'id' => $productId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$statement->fetchColumn()) {
|
||||||
|
throw new RuntimeException('Das gewaehlte Produkt gehoert nicht zu diesem Mandanten.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertTenantMembership(int $tenantId, int $userId): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id
|
||||||
|
FROM tenant_memberships
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND user_id = :user_id
|
||||||
|
AND status = "active"
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$statement->fetchColumn()) {
|
||||||
|
throw new RuntimeException('Der Benutzer ist fuer diesen Mandanten nicht aktiv.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ledgerEntry(int $tenantId, int $ledgerEntryId): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT *
|
||||||
|
FROM ledger_entries
|
||||||
|
WHERE id = :id
|
||||||
|
AND tenant_id = :tenant_id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'id' => $ledgerEntryId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
]);
|
||||||
|
$entry = $statement->fetch();
|
||||||
|
|
||||||
|
if (!$entry) {
|
||||||
|
throw new RuntimeException('Der angeforderte Vorgang wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasReversal(int $tenantId, int $ledgerEntryId): bool
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id
|
||||||
|
FROM ledger_entries
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND reversal_of_entry_id = :reversal_of_entry_id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'reversal_of_entry_id' => $ledgerEntryId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (bool) $statement->fetchColumn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class MailerService
|
||||||
|
{
|
||||||
|
private readonly string $rootPath;
|
||||||
|
|
||||||
|
public function __construct(private readonly array $config = [])
|
||||||
|
{
|
||||||
|
$this->rootPath = dirname(__DIR__, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $to, string $subject, string $textBody, array $headers = []): bool
|
||||||
|
{
|
||||||
|
$to = trim($to);
|
||||||
|
$subject = trim($subject);
|
||||||
|
$textBody = trim($textBody);
|
||||||
|
|
||||||
|
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new RuntimeException('Die Zieladresse fuer den Mailversand ist ungueltig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subject === '' || $textBody === '') {
|
||||||
|
throw new RuntimeException('Betreff und Inhalt fuer den Mailversand sind erforderlich.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectLine = $this->encodeSubject($this->sanitizeHeaderValue($this->subjectPrefix() . $subject));
|
||||||
|
$headerLines = $this->buildHeaders($headers);
|
||||||
|
$body = $this->normalizeBody($textBody);
|
||||||
|
|
||||||
|
$sent = false;
|
||||||
|
|
||||||
|
if (function_exists('mail')) {
|
||||||
|
$sent = @mail($to, $subjectLine, $body, implode("\r\n", $headerLines));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sent) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->usesLogFallback()) {
|
||||||
|
throw new RuntimeException('Die E-Mail konnte nicht versendet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeFallbackLog([
|
||||||
|
'to' => $to,
|
||||||
|
'subject' => $this->subjectPrefix() . $subject,
|
||||||
|
'headers' => $headerLines,
|
||||||
|
'body' => $textBody,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildHeaders(array $headers): array
|
||||||
|
{
|
||||||
|
$headerLines = [
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
'Content-Transfer-Encoding: 8bit',
|
||||||
|
'X-Mailer: Kaffeeliste-Neustart',
|
||||||
|
];
|
||||||
|
|
||||||
|
$from = trim((string) ($this->config['from'] ?? ''));
|
||||||
|
|
||||||
|
if ($from !== '') {
|
||||||
|
$headerLines[] = 'From: ' . $this->sanitizeHeaderValue($from);
|
||||||
|
}
|
||||||
|
|
||||||
|
$replyTo = trim((string) ($this->config['reply_to'] ?? ''));
|
||||||
|
|
||||||
|
if ($replyTo !== '') {
|
||||||
|
$headerLines[] = 'Reply-To: ' . $this->sanitizeHeaderValue($replyTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
if (is_int($name)) {
|
||||||
|
$line = trim((string) $value);
|
||||||
|
|
||||||
|
if ($line !== '' && str_contains($line, ':')) {
|
||||||
|
[$rawName, $rawValue] = array_pad(explode(':', $line, 2), 2, '');
|
||||||
|
$this->appendHeader($headerLines, $rawName, $rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->appendHeader($headerLines, (string) $name, (string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headerLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function appendHeader(array &$headerLines, string $name, string $value): void
|
||||||
|
{
|
||||||
|
$name = trim($name);
|
||||||
|
|
||||||
|
if ($name === '' || preg_match('/[^A-Za-z0-9-]/', $name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerLines[] = $name . ': ' . $this->sanitizeHeaderValue($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeFallbackLog(array $payload): void
|
||||||
|
{
|
||||||
|
$path = $this->resolveLogPath();
|
||||||
|
$directory = dirname($path);
|
||||||
|
|
||||||
|
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||||
|
throw new RuntimeException('Das Mail-Log-Verzeichnis konnte nicht erstellt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$encodedPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
if (!is_string($encodedPayload)) {
|
||||||
|
$encodedPayload = '{"error":"mail payload could not be encoded"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = '[' . gmdate(DATE_ATOM) . "] mail_fallback\n" . $encodedPayload . "\n\n";
|
||||||
|
|
||||||
|
if (file_put_contents($path, $entry, FILE_APPEND | LOCK_EX) === false) {
|
||||||
|
throw new RuntimeException('Das Mail-Fallback-Log konnte nicht geschrieben werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLogPath(): string
|
||||||
|
{
|
||||||
|
$configuredPath = trim((string) ($this->config['log_file'] ?? ''));
|
||||||
|
|
||||||
|
if ($configuredPath === '') {
|
||||||
|
return $this->rootPath . '/storage/logs/mail.log';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($configuredPath, '/')) {
|
||||||
|
return $configuredPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->rootPath . '/' . ltrim($configuredPath, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function usesLogFallback(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->config['log_fallback'] ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subjectPrefix(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->config['subject_prefix'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeHeaderValue(string $value): string
|
||||||
|
{
|
||||||
|
return trim(str_replace(["\r", "\n"], ' ', $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodeSubject(string $subject): string
|
||||||
|
{
|
||||||
|
if ($subject === '') {
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('mb_encode_mimeheader')) {
|
||||||
|
return mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBody(string $textBody): string
|
||||||
|
{
|
||||||
|
return str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $textBody));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class PasswordResetService
|
||||||
|
{
|
||||||
|
private readonly DateTimeZone $utc;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo,
|
||||||
|
private readonly MailerService $mailer,
|
||||||
|
private readonly array $config = []
|
||||||
|
) {
|
||||||
|
$this->utc = new DateTimeZone('UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestReset(string $email, array $context = []): void
|
||||||
|
{
|
||||||
|
$email = $this->normalizeEmail($email);
|
||||||
|
|
||||||
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->findUserByEmail($email);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->createTokenForUser((int) $user['id'], $context);
|
||||||
|
$this->sendResetMail($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createTokenForUser(int $userId, array $context = []): array
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
throw new RuntimeException('Der Benutzer fuer den Passwort-Reset ist ungueltig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->findUserById($userId);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
throw new RuntimeException('Der Benutzer fuer den Passwort-Reset wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$nowTs = time();
|
||||||
|
$now = $this->formatTimestamp($nowTs);
|
||||||
|
$expiresAt = $this->formatTimestamp($nowTs + ($this->ttlMinutes() * 60));
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$tokenHash = hash('sha256', $token);
|
||||||
|
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||||
|
$userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||||
|
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cleanupStatement = $this->pdo->prepare(
|
||||||
|
'DELETE FROM password_reset_tokens
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
AND (consumed_at IS NOT NULL OR expires_at < :now)'
|
||||||
|
);
|
||||||
|
$cleanupStatement->execute([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'now' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$insertStatement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO password_reset_tokens (
|
||||||
|
user_id,
|
||||||
|
email,
|
||||||
|
token_hash,
|
||||||
|
requested_ip_hash,
|
||||||
|
requested_user_agent_hash,
|
||||||
|
expires_at,
|
||||||
|
consumed_at,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
:user_id,
|
||||||
|
:email,
|
||||||
|
:token_hash,
|
||||||
|
:requested_ip_hash,
|
||||||
|
:requested_user_agent_hash,
|
||||||
|
:expires_at,
|
||||||
|
NULL,
|
||||||
|
:created_at
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$insertStatement->execute([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'email' => $user['email'],
|
||||||
|
'token_hash' => $tokenHash,
|
||||||
|
'requested_ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
|
||||||
|
'requested_user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$trimStatement = $this->pdo->prepare(
|
||||||
|
'SELECT id
|
||||||
|
FROM password_reset_tokens
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
AND consumed_at IS NULL
|
||||||
|
AND expires_at >= :now
|
||||||
|
ORDER BY created_at DESC, id DESC'
|
||||||
|
);
|
||||||
|
$trimStatement->execute([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'now' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tokenIds = array_map(
|
||||||
|
static fn (array $row): int => (int) $row['id'],
|
||||||
|
$trimStatement->fetchAll()
|
||||||
|
);
|
||||||
|
|
||||||
|
$idsToDelete = array_slice($tokenIds, $this->maxActiveTokens());
|
||||||
|
|
||||||
|
if ($idsToDelete !== []) {
|
||||||
|
$deleteStatement = $this->pdo->prepare(
|
||||||
|
'DELETE FROM password_reset_tokens
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
AND id = :id'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($idsToDelete as $tokenId) {
|
||||||
|
$deleteStatement->execute([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'id' => $tokenId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdo->commit();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'email' => $user['email'],
|
||||||
|
'full_name' => $user['full_name'],
|
||||||
|
'token' => $token,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'reset_url' => $this->buildResetUrl($token, $context),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findValidToken(string $token): ?array
|
||||||
|
{
|
||||||
|
$token = trim($token);
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT prt.id,
|
||||||
|
prt.user_id,
|
||||||
|
prt.email,
|
||||||
|
prt.expires_at,
|
||||||
|
prt.consumed_at,
|
||||||
|
prt.created_at,
|
||||||
|
u.full_name
|
||||||
|
FROM password_reset_tokens prt
|
||||||
|
INNER JOIN users u ON u.id = prt.user_id
|
||||||
|
WHERE prt.token_hash = :token_hash
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'token_hash' => hash('sha256', $token),
|
||||||
|
]);
|
||||||
|
$resetToken = $statement->fetch();
|
||||||
|
|
||||||
|
if (!$resetToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$resetToken['consumed_at'] !== null
|
||||||
|
|| $expiresAtTs === null
|
||||||
|
|| $expiresAtTs < time()
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resetToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(string $token, string $newPassword): int
|
||||||
|
{
|
||||||
|
$token = trim($token);
|
||||||
|
$newPassword = (string) $newPassword;
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($newPassword) < 12) {
|
||||||
|
throw new RuntimeException('Das neue Passwort muss mindestens 12 Zeichen lang sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenHash = hash('sha256', $token);
|
||||||
|
$nowTs = time();
|
||||||
|
$now = $this->formatTimestamp($nowTs);
|
||||||
|
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, user_id, expires_at, consumed_at
|
||||||
|
FROM password_reset_tokens
|
||||||
|
WHERE token_hash = :token_hash
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE'
|
||||||
|
);
|
||||||
|
$statement->execute(['token_hash' => $tokenHash]);
|
||||||
|
$resetToken = $statement->fetch();
|
||||||
|
|
||||||
|
if (!$resetToken) {
|
||||||
|
throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$resetToken['consumed_at'] !== null
|
||||||
|
|| $expiresAtTs === null
|
||||||
|
|| $expiresAtTs < $nowTs
|
||||||
|
) {
|
||||||
|
throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$updateUserStatement = $this->pdo->prepare(
|
||||||
|
'UPDATE users
|
||||||
|
SET password_hash = :password_hash
|
||||||
|
WHERE id = :user_id'
|
||||||
|
);
|
||||||
|
$updateUserStatement->execute([
|
||||||
|
'password_hash' => secure_password_hash($newPassword),
|
||||||
|
'user_id' => $resetToken['user_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$consumeTokenStatement = $this->pdo->prepare(
|
||||||
|
'UPDATE password_reset_tokens
|
||||||
|
SET consumed_at = :consumed_at
|
||||||
|
WHERE id = :id'
|
||||||
|
);
|
||||||
|
$consumeTokenStatement->execute([
|
||||||
|
'consumed_at' => $now,
|
||||||
|
'id' => $resetToken['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$revokeOtherTokensStatement = $this->pdo->prepare(
|
||||||
|
'DELETE FROM password_reset_tokens
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
AND id <> :id'
|
||||||
|
);
|
||||||
|
$revokeOtherTokensStatement->execute([
|
||||||
|
'user_id' => $resetToken['user_id'],
|
||||||
|
'id' => $resetToken['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->pdo->commit();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $resetToken['user_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeTokensForUser(int $userId): int
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'DELETE FROM password_reset_tokens
|
||||||
|
WHERE user_id = :user_id'
|
||||||
|
);
|
||||||
|
$statement->execute(['user_id' => $userId]);
|
||||||
|
|
||||||
|
return $statement->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purgeExpiredTokens(): int
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'DELETE FROM password_reset_tokens
|
||||||
|
WHERE consumed_at IS NOT NULL
|
||||||
|
OR expires_at < :now'
|
||||||
|
);
|
||||||
|
$statement->execute(['now' => $this->formatTimestamp(time())]);
|
||||||
|
|
||||||
|
return $statement->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendResetMail(array $token): void
|
||||||
|
{
|
||||||
|
$name = trim((string) ($token['full_name'] ?? ''));
|
||||||
|
$greeting = $name !== '' ? 'Hallo ' . $name . ',' : 'Hallo,';
|
||||||
|
$body = implode("\n\n", [
|
||||||
|
$greeting,
|
||||||
|
'fuer dein Konto wurde ein Passwort-Reset angefragt.',
|
||||||
|
'Bitte nutze diesen Link, um ein neues Passwort zu setzen:',
|
||||||
|
(string) $token['reset_url'],
|
||||||
|
'Der Link ist gueltig bis ' . (string) $token['expires_at'] . ' UTC.',
|
||||||
|
'Falls du den Reset nicht angefragt hast, kannst du diese Nachricht ignorieren.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mailer->send(
|
||||||
|
(string) $token['email'],
|
||||||
|
$this->mailSubject(),
|
||||||
|
$body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findUserByEmail(string $email): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, full_name, email
|
||||||
|
FROM users
|
||||||
|
WHERE email = :email
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['email' => $email]);
|
||||||
|
$user = $statement->fetch();
|
||||||
|
|
||||||
|
return $user ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findUserById(int $userId): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, full_name, email
|
||||||
|
FROM users
|
||||||
|
WHERE id = :id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $userId]);
|
||||||
|
$user = $statement->fetch();
|
||||||
|
|
||||||
|
return $user ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeEmail(string $email): string
|
||||||
|
{
|
||||||
|
return mb_strtolower(trim($email));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildResetUrl(string $token, array $context = []): string
|
||||||
|
{
|
||||||
|
$template = trim((string) ($this->config['reset_url'] ?? ''));
|
||||||
|
|
||||||
|
if ($template === '') {
|
||||||
|
$template = 'http://localhost:8080/reset-password?token={{token}}';
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = [];
|
||||||
|
|
||||||
|
foreach ($context as $key => $value) {
|
||||||
|
if (!is_string($key) || trim($key) === '' || !is_scalar($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedValue = trim((string) $value);
|
||||||
|
|
||||||
|
if ($normalizedValue === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query[$key] = $normalizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($template, '{{token}}')) {
|
||||||
|
$url = str_replace('{{token}}', rawurlencode($token), $template);
|
||||||
|
|
||||||
|
if ($query === []) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$separator = str_contains($url, '?') ? '&' : '?';
|
||||||
|
|
||||||
|
return $url . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query['token'] = $token;
|
||||||
|
$separator = str_contains($template, '?') ? '&' : '?';
|
||||||
|
|
||||||
|
return $template . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mailSubject(): string
|
||||||
|
{
|
||||||
|
$subject = trim((string) ($this->config['subject'] ?? ''));
|
||||||
|
|
||||||
|
return $subject !== '' ? $subject : 'Passwort zuruecksetzen';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ttlMinutes(): int
|
||||||
|
{
|
||||||
|
return max(5, (int) ($this->config['ttl_minutes'] ?? 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maxActiveTokens(): int
|
||||||
|
{
|
||||||
|
return max(1, (int) ($this->config['max_active_tokens'] ?? 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatTimestamp(int $timestamp): string
|
||||||
|
{
|
||||||
|
return gmdate('Y-m-d H:i:s', $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function timestampFromDatabase(?string $value): ?int
|
||||||
|
{
|
||||||
|
if ($value === null || trim($value) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
|
||||||
|
|
||||||
|
if (!$date instanceof DateTimeImmutable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date->getTimestamp();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class RateLimiterService
|
||||||
|
{
|
||||||
|
private readonly DateTimeZone $utc;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo,
|
||||||
|
private readonly array $config = []
|
||||||
|
) {
|
||||||
|
$this->utc = new DateTimeZone('UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inspect(string $action, string $scope): array
|
||||||
|
{
|
||||||
|
$policy = $this->resolvePolicy($action);
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT attempts, window_start, last_attempt_at, blocked_until
|
||||||
|
FROM rate_limits
|
||||||
|
WHERE action_key = :action_key
|
||||||
|
AND bucket_key = :bucket_key
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'action_key' => $policy['action'],
|
||||||
|
'bucket_key' => $this->bucketKey($scope),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->buildState($policy, $statement->fetch() ?: null, time());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hit(string $action, string $scope): array
|
||||||
|
{
|
||||||
|
$policy = $this->resolvePolicy($action);
|
||||||
|
$bucketKey = $this->bucketKey($scope);
|
||||||
|
$nowTs = time();
|
||||||
|
$now = $this->formatTimestamp($nowTs);
|
||||||
|
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$bootstrapStatement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO rate_limits (
|
||||||
|
action_key,
|
||||||
|
bucket_key,
|
||||||
|
attempts,
|
||||||
|
window_start,
|
||||||
|
last_attempt_at,
|
||||||
|
blocked_until,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
:action_key,
|
||||||
|
:bucket_key,
|
||||||
|
0,
|
||||||
|
:window_start,
|
||||||
|
:last_attempt_at,
|
||||||
|
NULL,
|
||||||
|
:created_at
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
action_key = action_key'
|
||||||
|
);
|
||||||
|
$bootstrapStatement->execute([
|
||||||
|
'action_key' => $policy['action'],
|
||||||
|
'bucket_key' => $bucketKey,
|
||||||
|
'window_start' => $now,
|
||||||
|
'last_attempt_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$selectStatement = $this->pdo->prepare(
|
||||||
|
'SELECT attempts, window_start, last_attempt_at, blocked_until
|
||||||
|
FROM rate_limits
|
||||||
|
WHERE action_key = :action_key
|
||||||
|
AND bucket_key = :bucket_key
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE'
|
||||||
|
);
|
||||||
|
$selectStatement->execute([
|
||||||
|
'action_key' => $policy['action'],
|
||||||
|
'bucket_key' => $bucketKey,
|
||||||
|
]);
|
||||||
|
$row = $selectStatement->fetch();
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
throw new RuntimeException('Der Rate-Limit-Zustand konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
|
||||||
|
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
|
||||||
|
$isBlocked = $blockedUntilTs !== null && $blockedUntilTs > $nowTs;
|
||||||
|
$resetWindow = $windowStartTs === null
|
||||||
|
|| ($windowStartTs + $policy['window_seconds']) <= $nowTs
|
||||||
|
|| ($blockedUntilTs !== null && $blockedUntilTs <= $nowTs);
|
||||||
|
|
||||||
|
if ($isBlocked) {
|
||||||
|
$updatedRow = $row;
|
||||||
|
} else {
|
||||||
|
$attempts = $resetWindow ? 1 : ((int) $row['attempts'] + 1);
|
||||||
|
$windowStart = $resetWindow ? $now : (string) $row['window_start'];
|
||||||
|
$blockedUntil = $attempts >= $policy['max_attempts']
|
||||||
|
? $this->formatTimestamp($nowTs + $policy['block_seconds'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$updateStatement = $this->pdo->prepare(
|
||||||
|
'UPDATE rate_limits
|
||||||
|
SET attempts = :attempts,
|
||||||
|
window_start = :window_start,
|
||||||
|
last_attempt_at = :last_attempt_at,
|
||||||
|
blocked_until = :blocked_until
|
||||||
|
WHERE action_key = :action_key
|
||||||
|
AND bucket_key = :bucket_key'
|
||||||
|
);
|
||||||
|
$updateStatement->execute([
|
||||||
|
'attempts' => $attempts,
|
||||||
|
'window_start' => $windowStart,
|
||||||
|
'last_attempt_at' => $now,
|
||||||
|
'blocked_until' => $blockedUntil,
|
||||||
|
'action_key' => $policy['action'],
|
||||||
|
'bucket_key' => $bucketKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updatedRow = [
|
||||||
|
'attempts' => $attempts,
|
||||||
|
'window_start' => $windowStart,
|
||||||
|
'last_attempt_at' => $now,
|
||||||
|
'blocked_until' => $blockedUntil,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdo->commit();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildState($policy, $updatedRow, $nowTs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(string $action, string $scope): void
|
||||||
|
{
|
||||||
|
$policy = $this->resolvePolicy($action);
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'DELETE FROM rate_limits
|
||||||
|
WHERE action_key = :action_key
|
||||||
|
AND bucket_key = :bucket_key'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'action_key' => $policy['action'],
|
||||||
|
'bucket_key' => $this->bucketKey($scope),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tooManyAttempts(string $action, string $scope): bool
|
||||||
|
{
|
||||||
|
return !$this->inspect($action, $scope)['allowed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prune(int $staleAfterSeconds = 604800): int
|
||||||
|
{
|
||||||
|
$cutoff = $this->formatTimestamp(time() - max(3600, $staleAfterSeconds));
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'DELETE FROM rate_limits
|
||||||
|
WHERE (blocked_until IS NULL AND last_attempt_at < :cutoff)
|
||||||
|
OR (blocked_until IS NOT NULL AND blocked_until < :cutoff)'
|
||||||
|
);
|
||||||
|
$statement->execute(['cutoff' => $cutoff]);
|
||||||
|
|
||||||
|
return $statement->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildState(array $policy, ?array $row, int $nowTs): array
|
||||||
|
{
|
||||||
|
$attempts = 0;
|
||||||
|
$blockedUntil = null;
|
||||||
|
$retryAfter = 0;
|
||||||
|
$allowed = true;
|
||||||
|
$resetAtTs = $nowTs + $policy['window_seconds'];
|
||||||
|
|
||||||
|
if ($row) {
|
||||||
|
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
|
||||||
|
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
|
||||||
|
|
||||||
|
if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
|
||||||
|
$attempts = (int) $row['attempts'];
|
||||||
|
$blockedUntil = (string) $row['blocked_until'];
|
||||||
|
$retryAfter = max(1, $blockedUntilTs - $nowTs);
|
||||||
|
$allowed = false;
|
||||||
|
$resetAtTs = $blockedUntilTs;
|
||||||
|
} elseif ($windowStartTs !== null && ($windowStartTs + $policy['window_seconds']) > $nowTs) {
|
||||||
|
$attempts = (int) $row['attempts'];
|
||||||
|
$resetAtTs = $windowStartTs + $policy['window_seconds'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'action' => $policy['action'],
|
||||||
|
'allowed' => $allowed,
|
||||||
|
'attempts' => $attempts,
|
||||||
|
'max_attempts' => $policy['max_attempts'],
|
||||||
|
'remaining_attempts' => max(0, $policy['max_attempts'] - $attempts),
|
||||||
|
'window_seconds' => $policy['window_seconds'],
|
||||||
|
'block_seconds' => $policy['block_seconds'],
|
||||||
|
'retry_after_seconds' => $retryAfter,
|
||||||
|
'reset_at' => $this->formatTimestamp($resetAtTs),
|
||||||
|
'blocked_until' => $blockedUntil,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePolicy(string $action): array
|
||||||
|
{
|
||||||
|
$normalizedAction = mb_strtolower(trim($action));
|
||||||
|
|
||||||
|
if ($normalizedAction === '') {
|
||||||
|
throw new RuntimeException('Die Rate-Limit-Aktion ist erforderlich.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = $this->config[$normalizedAction] ?? null;
|
||||||
|
|
||||||
|
if (!is_array($policy)) {
|
||||||
|
throw new RuntimeException(sprintf('Fuer die Aktion "%s" ist kein Rate-Limit konfiguriert.', $normalizedAction));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'action' => $normalizedAction,
|
||||||
|
'max_attempts' => max(1, (int) ($policy['max_attempts'] ?? 5)),
|
||||||
|
'window_seconds' => max(60, (int) ($policy['window_seconds'] ?? 900)),
|
||||||
|
'block_seconds' => max(60, (int) ($policy['block_seconds'] ?? 900)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bucketKey(string $scope): string
|
||||||
|
{
|
||||||
|
$scope = trim($scope);
|
||||||
|
|
||||||
|
if ($scope === '') {
|
||||||
|
throw new RuntimeException('Der Rate-Limit-Scope ist erforderlich.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash('sha256', $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatTimestamp(int $timestamp): string
|
||||||
|
{
|
||||||
|
return gmdate('Y-m-d H:i:s', $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function timestampFromDatabase(?string $value): ?int
|
||||||
|
{
|
||||||
|
if ($value === null || trim($value) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
|
||||||
|
|
||||||
|
if (!$date instanceof DateTimeImmutable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date->getTimestamp();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ final class RfidService
|
|||||||
$token = bin2hex(random_bytes(20));
|
$token = bin2hex(random_bytes(20));
|
||||||
$statement = $this->pdo->prepare(
|
$statement = $this->pdo->prepare(
|
||||||
'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status)
|
'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status)
|
||||||
VALUES (:tenant_id, :name, :location_label, :device_token, "planned")'
|
VALUES (:tenant_id, :name, :location_label, :device_token, "active")'
|
||||||
);
|
);
|
||||||
$statement->execute([
|
$statement->execute([
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
@@ -43,6 +43,18 @@ final class RfidService
|
|||||||
throw new RuntimeException('Die Karten-UID darf nicht leer sein.');
|
throw new RuntimeException('Die Karten-UID darf nicht leer sein.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$memberStatement = $this->pdo->prepare(
|
||||||
|
'SELECT id FROM members WHERE tenant_id = :tenant_id AND id = :id AND status = "active" LIMIT 1'
|
||||||
|
);
|
||||||
|
$memberStatement->execute([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'id' => $memberId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$memberStatement->fetchColumn()) {
|
||||||
|
throw new RuntimeException('Das ausgewaehlte Mitglied ist fuer RFID nicht verfuegbar.');
|
||||||
|
}
|
||||||
|
|
||||||
$statement = $this->pdo->prepare(
|
$statement = $this->pdo->prepare(
|
||||||
'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status)
|
'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status)
|
||||||
VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")'
|
VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")'
|
||||||
@@ -80,6 +92,10 @@ final class RfidService
|
|||||||
throw new RuntimeException('Das RFID-Geraet ist unbekannt.');
|
throw new RuntimeException('Das RFID-Geraet ist unbekannt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (($device['status'] ?? '') !== 'active') {
|
||||||
|
throw new RuntimeException('Das RFID-Geraet ist nicht aktiv.');
|
||||||
|
}
|
||||||
|
|
||||||
$status = 'received';
|
$status = 'received';
|
||||||
$tagStatement = $this->pdo->prepare(
|
$tagStatement = $this->pdo->prepare(
|
||||||
'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1'
|
'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1'
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
final class SecurityHeadersService
|
||||||
|
{
|
||||||
|
public function buildHeaders(?bool $isHttps = null): array
|
||||||
|
{
|
||||||
|
$isHttps = $isHttps ?? $this->isHttpsRequest();
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Content-Security-Policy' => $this->buildContentSecurityPolicy($isHttps),
|
||||||
|
'Referrer-Policy' => 'strict-origin-when-cross-origin',
|
||||||
|
'Permissions-Policy' => 'accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
|
||||||
|
'Cross-Origin-Opener-Policy' => 'same-origin',
|
||||||
|
'Cross-Origin-Resource-Policy' => 'same-origin',
|
||||||
|
'Origin-Agent-Cluster' => '?1',
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
'X-Frame-Options' => 'SAMEORIGIN',
|
||||||
|
'X-Permitted-Cross-Domain-Policies' => 'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($isHttps) {
|
||||||
|
$headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(array $overrides = [], ?bool $isHttps = null): void
|
||||||
|
{
|
||||||
|
if (headers_sent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = $this->buildHeaders($isHttps);
|
||||||
|
|
||||||
|
foreach ($overrides as $name => $value) {
|
||||||
|
if (!is_string($name) || trim($name) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
unset($headers[$name]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers[$name] = (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
header_remove('X-Powered-By');
|
||||||
|
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
header($name . ': ' . $value, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildContentSecurityPolicy(bool $isHttps): string
|
||||||
|
{
|
||||||
|
$directives = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"manifest-src 'self'",
|
||||||
|
"script-src 'self'",
|
||||||
|
"style-src 'self'",
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($isHttps) {
|
||||||
|
$directives[] = 'upgrade-insecure-requests';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('; ', $directives);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isHttpsRequest(): bool
|
||||||
|
{
|
||||||
|
$https = $_SERVER['HTTPS'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($https) && $https !== '' && strtolower($https) !== 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$forwardedProto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '';
|
||||||
|
|
||||||
|
if (is_string($forwardedProto) && strtolower($forwardedProto) === 'https') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($_SERVER['SERVER_PORT'] ?? '') === '443';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ final class SetupService
|
|||||||
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
|
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->resolveMailFrom($input, $this->normalizeAppUrl((string) $input['app_url']));
|
||||||
|
|
||||||
$config = [
|
$config = [
|
||||||
'host' => trim((string) $input['db_host']),
|
'host' => trim((string) $input['db_host']),
|
||||||
'port' => (int) $input['db_port'],
|
'port' => (int) $input['db_port'],
|
||||||
@@ -106,6 +108,8 @@ final class SetupService
|
|||||||
|
|
||||||
private function buildEnvFile(array $input): string
|
private function buildEnvFile(array $input): string
|
||||||
{
|
{
|
||||||
|
$appUrl = $this->normalizeAppUrl((string) $input['app_url']);
|
||||||
|
$mailFrom = $this->resolveMailFrom($input, $appUrl);
|
||||||
$appKey = bin2hex(random_bytes(32));
|
$appKey = bin2hex(random_bytes(32));
|
||||||
$rfidSecret = bin2hex(random_bytes(24));
|
$rfidSecret = bin2hex(random_bytes(24));
|
||||||
|
|
||||||
@@ -113,9 +117,11 @@ final class SetupService
|
|||||||
'APP_NAME="Kaffeekasse SaaS"',
|
'APP_NAME="Kaffeekasse SaaS"',
|
||||||
'APP_ENV=production',
|
'APP_ENV=production',
|
||||||
'APP_DEBUG=0',
|
'APP_DEBUG=0',
|
||||||
'APP_URL=' . trim((string) $input['app_url']),
|
'APP_URL=' . $appUrl,
|
||||||
'APP_TIMEZONE=Europe/Berlin',
|
'APP_TIMEZONE=Europe/Berlin',
|
||||||
'APP_KEY=' . $appKey,
|
'APP_KEY=' . $appKey,
|
||||||
|
'ALLOW_PUBLIC_REGISTRATION=1',
|
||||||
|
'RFID_INGEST_ENABLED=0',
|
||||||
'',
|
'',
|
||||||
'DB_HOST=' . trim((string) $input['db_host']),
|
'DB_HOST=' . trim((string) $input['db_host']),
|
||||||
'DB_PORT=' . (int) $input['db_port'],
|
'DB_PORT=' . (int) $input['db_port'],
|
||||||
@@ -123,10 +129,64 @@ final class SetupService
|
|||||||
'DB_USER=' . trim((string) $input['db_user']),
|
'DB_USER=' . trim((string) $input['db_user']),
|
||||||
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
|
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
|
||||||
'',
|
'',
|
||||||
'MAIL_FROM=' . trim((string) ($input['mail_from'] ?? 'noreply@example.com')),
|
'MAIL_FROM=' . $mailFrom,
|
||||||
|
'MAIL_REPLY_TO=' . $mailFrom,
|
||||||
|
'MAIL_SUBJECT_PREFIX="[Kaffeekasse] "',
|
||||||
|
'MAIL_LOG_FALLBACK=1',
|
||||||
|
'MAIL_LOG_FILE=storage/logs/mail.log',
|
||||||
|
'',
|
||||||
|
'PASSWORD_RESET_URL=' . $appUrl . '/reset-password?token={{token}}',
|
||||||
|
'PASSWORD_RESET_TTL_MINUTES=60',
|
||||||
|
'PASSWORD_RESET_MAX_ACTIVE_TOKENS=3',
|
||||||
|
'PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen"',
|
||||||
|
'',
|
||||||
|
'RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5',
|
||||||
|
'RATE_LIMIT_LOGIN_WINDOW_SECONDS=900',
|
||||||
|
'RATE_LIMIT_LOGIN_BLOCK_SECONDS=900',
|
||||||
|
'RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3',
|
||||||
|
'RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600',
|
||||||
|
'RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600',
|
||||||
|
'',
|
||||||
'RFID_SHARED_SECRET=' . $rfidSecret,
|
'RFID_SHARED_SECRET=' . $rfidSecret,
|
||||||
];
|
];
|
||||||
|
|
||||||
return implode(PHP_EOL, $lines) . PHP_EOL;
|
return implode(PHP_EOL, $lines) . PHP_EOL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeAppUrl(string $appUrl): string
|
||||||
|
{
|
||||||
|
return rtrim(trim($appUrl), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveMailFrom(array $input, string $appUrl): string
|
||||||
|
{
|
||||||
|
$mailFrom = mb_strtolower(trim((string) ($input['mail_from'] ?? '')));
|
||||||
|
|
||||||
|
if ($mailFrom === '' || $mailFrom === 'noreply@example.com') {
|
||||||
|
return $this->defaultMailFrom($appUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter_var($mailFrom, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new RuntimeException('Die Mail-Absenderadresse ist ungueltig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mailFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultMailFrom(string $appUrl): string
|
||||||
|
{
|
||||||
|
$host = parse_url($appUrl, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (!is_string($host)) {
|
||||||
|
return 'noreply@example.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = preg_replace('/^www\./i', '', mb_strtolower(trim($host))) ?? '';
|
||||||
|
|
||||||
|
if ($host === '' || !str_contains($host, '.')) {
|
||||||
|
return 'noreply@example.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'noreply@' . $host;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-1
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\MailerService;
|
||||||
|
use App\Services\PasswordResetService;
|
||||||
|
use App\Services\RateLimiterService;
|
||||||
|
|
||||||
$rootPath = dirname(__DIR__);
|
$rootPath = dirname(__DIR__);
|
||||||
$lockFile = $rootPath . '/storage/cron.lock';
|
$lockFile = $rootPath . '/storage/cron.lock';
|
||||||
|
|
||||||
@@ -20,6 +24,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$pdo = $app['database']->pdo();
|
$pdo = $app['database']->pdo();
|
||||||
|
$passwordResetService = new PasswordResetService(
|
||||||
|
$pdo,
|
||||||
|
new MailerService($app['config']['mail'] ?? []),
|
||||||
|
$app['config']['password_reset'] ?? []
|
||||||
|
);
|
||||||
|
$rateLimiterService = new RateLimiterService(
|
||||||
|
$pdo,
|
||||||
|
$app['config']['rate_limits'] ?? []
|
||||||
|
);
|
||||||
|
$purgedPasswordResetTokens = $passwordResetService->purgeExpiredTokens();
|
||||||
|
$prunedRateLimits = $rateLimiterService->prune();
|
||||||
|
|
||||||
$pendingRfid = (int) $pdo->query(
|
$pendingRfid = (int) $pdo->query(
|
||||||
'SELECT COUNT(*) FROM rfid_events WHERE status = "received"'
|
'SELECT COUNT(*) FROM rfid_events WHERE status = "received"'
|
||||||
@@ -27,9 +42,14 @@ try {
|
|||||||
|
|
||||||
$summary = [
|
$summary = [
|
||||||
'timestamp' => gmdate(DATE_ATOM),
|
'timestamp' => gmdate(DATE_ATOM),
|
||||||
|
'cleanup' => [
|
||||||
|
'password_reset_tokens_removed' => $purgedPasswordResetTokens,
|
||||||
|
'rate_limit_entries_removed' => $prunedRateLimits,
|
||||||
|
],
|
||||||
'pending_rfid_events' => $pendingRfid,
|
'pending_rfid_events' => $pendingRfid,
|
||||||
'notes' => [
|
'notes' => [
|
||||||
'In diesem MVP dient Cron als technischer Haken fuer spaetere Reminder, Exporte und RFID-Regeln.',
|
'Cron bereinigt abgelaufene Passwort-Reset-Tokens und veraltete Rate-Limit-Eintraege.',
|
||||||
|
'In diesem MVP dient Cron ausserdem als technischer Haken fuer spaetere Reminder, Exporte und RFID-Regeln.',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ return [
|
|||||||
'url' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'),
|
'url' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'),
|
||||||
'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin',
|
'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin',
|
||||||
'app_key' => $_ENV['APP_KEY'] ?? '',
|
'app_key' => $_ENV['APP_KEY'] ?? '',
|
||||||
|
'allow_public_registration' => (($_ENV['ALLOW_PUBLIC_REGISTRATION'] ?? '0') === '1'),
|
||||||
|
'rfid_ingest_enabled' => (($_ENV['RFID_INGEST_ENABLED'] ?? '0') === '1'),
|
||||||
'db' => [
|
'db' => [
|
||||||
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
|
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
|
||||||
'port' => (int) ($_ENV['DB_PORT'] ?? 3306),
|
'port' => (int) ($_ENV['DB_PORT'] ?? 3306),
|
||||||
@@ -15,5 +17,31 @@ return [
|
|||||||
'pass' => $_ENV['DB_PASS'] ?? '',
|
'pass' => $_ENV['DB_PASS'] ?? '',
|
||||||
'charset' => 'utf8mb4',
|
'charset' => 'utf8mb4',
|
||||||
],
|
],
|
||||||
|
'mail' => [
|
||||||
|
'from' => trim($_ENV['MAIL_FROM'] ?? 'noreply@example.com'),
|
||||||
|
'reply_to' => trim($_ENV['MAIL_REPLY_TO'] ?? ''),
|
||||||
|
'subject_prefix' => $_ENV['MAIL_SUBJECT_PREFIX'] ?? '[Kaffeekasse] ',
|
||||||
|
'log_fallback' => (($_ENV['MAIL_LOG_FALLBACK'] ?? '1') === '1'),
|
||||||
|
'log_file' => trim($_ENV['MAIL_LOG_FILE'] ?? ''),
|
||||||
|
],
|
||||||
|
'password_reset' => [
|
||||||
|
'reset_url' => $_ENV['PASSWORD_RESET_URL']
|
||||||
|
?? rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/') . '/reset-password?token={{token}}',
|
||||||
|
'ttl_minutes' => max(5, (int) ($_ENV['PASSWORD_RESET_TTL_MINUTES'] ?? 60)),
|
||||||
|
'max_active_tokens' => max(1, (int) ($_ENV['PASSWORD_RESET_MAX_ACTIVE_TOKENS'] ?? 3)),
|
||||||
|
'subject' => $_ENV['PASSWORD_RESET_SUBJECT'] ?? 'Passwort zuruecksetzen',
|
||||||
|
],
|
||||||
|
'rate_limits' => [
|
||||||
|
'login' => [
|
||||||
|
'max_attempts' => max(1, (int) ($_ENV['RATE_LIMIT_LOGIN_MAX_ATTEMPTS'] ?? 5)),
|
||||||
|
'window_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_LOGIN_WINDOW_SECONDS'] ?? 900)),
|
||||||
|
'block_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_LOGIN_BLOCK_SECONDS'] ?? 900)),
|
||||||
|
],
|
||||||
|
'registration' => [
|
||||||
|
'max_attempts' => max(1, (int) ($_ENV['RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS'] ?? 3)),
|
||||||
|
'window_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_REGISTRATION_WINDOW_SECONDS'] ?? 3600)),
|
||||||
|
'block_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_REGISTRATION_BLOCK_SECONDS'] ?? 3600)),
|
||||||
|
],
|
||||||
|
],
|
||||||
'rfid_shared_secret' => $_ENV['RFID_SHARED_SECRET'] ?? '',
|
'rfid_shared_secret' => $_ENV['RFID_SHARED_SECRET'] ?? '',
|
||||||
];
|
];
|
||||||
|
|||||||
+31
-1
@@ -7,6 +7,36 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
email VARCHAR(190) NOT NULL,
|
||||||
|
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||||
|
requested_ip_hash CHAR(64) NULL,
|
||||||
|
requested_user_agent_hash CHAR(64) NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
consumed_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_password_reset_user_created (user_id, created_at),
|
||||||
|
INDEX idx_password_reset_expires (expires_at),
|
||||||
|
CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
action_key VARCHAR(80) NOT NULL,
|
||||||
|
bucket_key CHAR(64) NOT NULL,
|
||||||
|
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
window_start DATETIME NOT NULL,
|
||||||
|
last_attempt_at DATETIME NOT NULL,
|
||||||
|
blocked_until DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_rate_limit_bucket (action_key, bucket_key),
|
||||||
|
INDEX idx_rate_limit_blocked_until (blocked_until),
|
||||||
|
INDEX idx_rate_limit_last_attempt (last_attempt_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(190) NOT NULL,
|
name VARCHAR(190) NOT NULL,
|
||||||
@@ -57,7 +87,7 @@ CREATE TABLE IF NOT EXISTS members (
|
|||||||
user_id BIGINT UNSIGNED NULL,
|
user_id BIGINT UNSIGNED NULL,
|
||||||
display_name VARCHAR(160) NOT NULL,
|
display_name VARCHAR(160) NOT NULL,
|
||||||
email VARCHAR(190) NULL,
|
email VARCHAR(190) NULL,
|
||||||
access_pin VARCHAR(20) NULL,
|
access_pin VARCHAR(255) NULL,
|
||||||
status ENUM('active', 'archived') NOT NULL DEFAULT 'active',
|
status ENUM('active', 'archived') NOT NULL DEFAULT 'active',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY uniq_member_email (tenant_id, email),
|
UNIQUE KEY uniq_member_email (tenant_id, email),
|
||||||
|
|||||||
+30
-1
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
|||||||
use App\Controllers\PlatformController;
|
use App\Controllers\PlatformController;
|
||||||
use App\Controllers\TenantController;
|
use App\Controllers\TenantController;
|
||||||
use App\Core\Response;
|
use App\Core\Response;
|
||||||
|
use App\Services\SecurityHeadersService;
|
||||||
|
|
||||||
$app = require dirname(__DIR__) . '/app/bootstrap.php';
|
$app = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
(new SecurityHeadersService())->send();
|
||||||
|
|
||||||
$platform = new PlatformController(
|
$platform = new PlatformController(
|
||||||
$app['rootPath'],
|
$app['rootPath'],
|
||||||
$app['config'],
|
$app['config'],
|
||||||
@@ -21,7 +24,8 @@ $tenantController = new TenantController(
|
|||||||
$app['database'],
|
$app['database'],
|
||||||
$app['view'],
|
$app['view'],
|
||||||
$app['session'],
|
$app['session'],
|
||||||
$app['csrf']
|
$app['csrf'],
|
||||||
|
$app['config']
|
||||||
);
|
);
|
||||||
|
|
||||||
$path = current_path();
|
$path = current_path();
|
||||||
@@ -48,6 +52,16 @@ try {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($path === '/password/forgot') {
|
||||||
|
$method === 'POST' ? $platform->passwordResetRequestSubmit() : $platform->passwordResetRequestForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/reset-password') {
|
||||||
|
$method === 'POST' ? $platform->passwordResetConfirmSubmit() : $platform->passwordResetConfirmForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($path === '/admin' && $method === 'GET') {
|
if ($path === '/admin' && $method === 'GET') {
|
||||||
$platform->adminDashboard();
|
$platform->adminDashboard();
|
||||||
return;
|
return;
|
||||||
@@ -59,6 +73,11 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($path === '/api/rfid/intake' && $method === 'POST') {
|
if ($path === '/api/rfid/intake' && $method === 'POST') {
|
||||||
|
if (!($app['config']['rfid_ingest_enabled'] ?? false)) {
|
||||||
|
Response::notFound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$tenantController->rfidIngest();
|
$tenantController->rfidIngest();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,6 +116,16 @@ try {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'exports/balances.csv' && $method === 'GET') {
|
||||||
|
$tenantController->exportBalances($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subPath === 'exports/bookings.csv' && $method === 'GET') {
|
||||||
|
$tenantController->exportBookings($tenantSlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($subPath === 'payments') {
|
if ($subPath === 'payments') {
|
||||||
$tenantController->payments($tenantSlug);
|
$tenantController->payments($tenantSlug);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
<?php
|
||||||
|
$resetRequestUrl = '/password/forgot?scope=platform';
|
||||||
|
require __DIR__ . '/../partials/layout-top.php';
|
||||||
|
?>
|
||||||
|
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<span class="eyebrow">Plattform</span>
|
<span class="eyebrow">Plattformzugang</span>
|
||||||
<h1>Admin-Login</h1>
|
<h1>Admin-Login</h1>
|
||||||
<p>Fuer Betrieb, Mandantenuebersicht, Support und spaetere Vermarktungssteuerung.</p>
|
<p>Fuer Pilotbetreuung, Mandantenuebersicht und Betriebsaufgaben. Mitglieder nutzen dafuer nicht diese Seite, sondern den jeweiligen Standort-Login.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form class="card form-card slim" method="post" action="/admin/login">
|
<div class="two-column">
|
||||||
<?= csrf_field($csrf) ?>
|
<form class="card form-card" method="post" action="/admin/login">
|
||||||
<label>
|
<?= csrf_field($csrf) ?>
|
||||||
<span>E-Mail</span>
|
<label>
|
||||||
<input type="email" name="email" value="<?= e((string) old('email')) ?>" required>
|
<span>E-Mail</span>
|
||||||
</label>
|
<input type="email" name="email" value="<?= e((string) old('email')) ?>" required>
|
||||||
<label>
|
</label>
|
||||||
<span>Passwort</span>
|
<label>
|
||||||
<input type="password" name="password" required>
|
<span>Passwort</span>
|
||||||
</label>
|
<input type="password" name="password" required>
|
||||||
<button class="button button-primary" type="submit">Anmelden</button>
|
</label>
|
||||||
</form>
|
<button class="button button-primary" type="submit">Plattform anmelden</button>
|
||||||
|
<p class="muted">Passwort vergessen? <a class="text-link" href="<?= e($resetRequestUrl) ?>">Reset anstossen</a></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside class="card" id="reset-help">
|
||||||
|
<span class="eyebrow">Passwort vergessen</span>
|
||||||
|
<h2>Reset im Pilotbetrieb</h2>
|
||||||
|
<p class="muted">Der Passwort-Reset ist fuer den Pilot bereits vorgesehen. Bis die automatische Zustellung freigeschaltet ist, sollte das Plattformteam die Anfrage pruefen und den Reset manuell begleiten.</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Nur fuer Plattform-Admins und Pilotbetreuung</li>
|
||||||
|
<li>Anfrage immer ueber die bekannte Login-E-Mail pruefen</li>
|
||||||
|
<li>Mandanten- und Member-Logins laufen getrennt vom Plattformzugang</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
$scope = trim((string) ($scope ?? old('scope')));
|
||||||
|
|
||||||
|
if ($scope === '') {
|
||||||
|
$scope = trim((string) ($_GET['scope'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = $scope === 'tenant' ? 'tenant' : 'platform';
|
||||||
|
|
||||||
|
$tenantSlug = trim((string) ($tenantSlug ?? old('tenant_slug')));
|
||||||
|
|
||||||
|
if ($tenantSlug === '') {
|
||||||
|
$tenantSlug = trim((string) ($_GET['tenantSlug'] ?? $_GET['tenant'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantName = trim((string) ($tenantName ?? old('tenant_name')));
|
||||||
|
|
||||||
|
if ($tenantName === '') {
|
||||||
|
$tenantName = trim((string) ($_GET['tenantName'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantName === '' && $tenantSlug !== '') {
|
||||||
|
$tenantName = 'Standort ' . $tenantSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantName === '') {
|
||||||
|
$tenantName = 'Standortzugang';
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = trim((string) ($token ?? old('token')));
|
||||||
|
$resetToken = isset($resetToken) && is_array($resetToken) ? $resetToken : null;
|
||||||
|
$hasValidResetToken = $resetToken !== null;
|
||||||
|
$loginUrl = $scope === 'tenant'
|
||||||
|
? ($tenantSlug !== '' ? tenant_url($tenantSlug, 'login') : '/')
|
||||||
|
: '/admin/login';
|
||||||
|
$requestUrl = '/password/forgot?scope=' . rawurlencode($scope);
|
||||||
|
|
||||||
|
if ($scope === 'tenant' && $tenantSlug !== '') {
|
||||||
|
$requestUrl .= '&tenantSlug=' . rawurlencode($tenantSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope === 'tenant' && $tenantName !== '') {
|
||||||
|
$requestUrl .= '&tenantName=' . rawurlencode($tenantName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resetEmail = trim((string) ($resetToken['email'] ?? ''));
|
||||||
|
$expiresAt = trim((string) ($resetToken['expires_at'] ?? ''));
|
||||||
|
require __DIR__ . '/../partials/layout-top.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Reset bestaetigen</span>
|
||||||
|
<h1>Neues Passwort setzen</h1>
|
||||||
|
<p>
|
||||||
|
<?= e($hasValidResetToken
|
||||||
|
? ($scope === 'platform'
|
||||||
|
? 'Der Plattformzugang bekommt hier sein neues Passwort.'
|
||||||
|
: 'Der Standortzugang fuer ' . $tenantName . ' bekommt hier sein neues Passwort.')
|
||||||
|
: 'Hier wird nur mit einem gueltigen Reset-Link ein neues Passwort gesetzt.') ?>
|
||||||
|
So bleibt der Login-Reset von PIN, RFID und sonstigen Standortdaten getrennt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<?php if ($hasValidResetToken): ?>
|
||||||
|
<form class="card form-card" method="post" action="<?= e(current_path()) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<input type="hidden" name="scope" value="<?= e($scope) ?>">
|
||||||
|
<?php if ($tenantSlug !== ''): ?>
|
||||||
|
<input type="hidden" name="tenant_slug" value="<?= e($tenantSlug) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($scope === 'tenant' && $tenantName !== ''): ?>
|
||||||
|
<input type="hidden" name="tenant_name" value="<?= e($tenantName) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="hidden" name="token" value="<?= e($token) ?>">
|
||||||
|
<?php if ($resetEmail !== '' || $expiresAt !== ''): ?>
|
||||||
|
<p class="muted">
|
||||||
|
<?= e($resetEmail !== '' ? 'Link fuer ' . $resetEmail . '.' : 'Der Reset-Link wurde geprueft.') ?>
|
||||||
|
<?php if ($expiresAt !== ''): ?>
|
||||||
|
Gueltig bis <?= e($expiresAt) ?> UTC.
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<label>
|
||||||
|
<span>Neues Passwort</span>
|
||||||
|
<input type="password" name="password" minlength="12" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Passwort wiederholen</span>
|
||||||
|
<input type="password" name="password_confirm" minlength="12" required>
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Passwort uebernehmen</button>
|
||||||
|
<p class="muted">Nach erfolgreicher Bestaetigung soll dieser Flow wieder in den passenden Login zurueckfuehren.</p>
|
||||||
|
<p><a class="text-link" href="<?= e($loginUrl) ?>">Zurueck zum Login</a></p>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="card form-card">
|
||||||
|
<h2>Reset-Link neu anfordern</h2>
|
||||||
|
<p class="muted">
|
||||||
|
<?= e($token === ''
|
||||||
|
? 'Oeffne den Reset-Link aus der E-Mail, um hier ein neues Passwort zu setzen.'
|
||||||
|
: 'Dieser Reset-Link ist ungueltig oder abgelaufen. Bitte fordere einen neuen Link an.') ?>
|
||||||
|
</p>
|
||||||
|
<p><a class="text-link" href="<?= e($requestUrl) ?>">Neuen Reset-Link anfordern</a></p>
|
||||||
|
<p><a class="text-link" href="<?= e($loginUrl) ?>">Zurueck zum Login</a></p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<aside class="card">
|
||||||
|
<span class="eyebrow">Worauf geachtet wird</span>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Mindestens 12 Zeichen fuer Plattform- und Standortzugang</li>
|
||||||
|
<li>Das neue Passwort ersetzt nur den Login, nicht PIN oder RFID-Zuweisungen</li>
|
||||||
|
<li>Bei Rueckfragen bleibt das Standortteam im Pilot die erste Anlaufstelle</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
$scope = trim((string) ($scope ?? old('scope')));
|
||||||
|
|
||||||
|
if ($scope === '') {
|
||||||
|
$scope = trim((string) ($_GET['scope'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = $scope === 'tenant' ? 'tenant' : 'platform';
|
||||||
|
|
||||||
|
$tenantSlug = trim((string) ($tenantSlug ?? old('tenant_slug')));
|
||||||
|
|
||||||
|
if ($tenantSlug === '') {
|
||||||
|
$tenantSlug = trim((string) ($_GET['tenantSlug'] ?? $_GET['tenant'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantName = trim((string) ($tenantName ?? old('tenant_name')));
|
||||||
|
|
||||||
|
if ($tenantName === '') {
|
||||||
|
$tenantName = trim((string) ($_GET['tenantName'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantName === '' && $tenantSlug !== '') {
|
||||||
|
$tenantName = 'Standort ' . $tenantSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantName === '') {
|
||||||
|
$tenantName = 'Standortzugang';
|
||||||
|
}
|
||||||
|
|
||||||
|
$loginUrl = $scope === 'tenant'
|
||||||
|
? ($tenantSlug !== '' ? tenant_url($tenantSlug, 'login') : '/')
|
||||||
|
: '/admin/login';
|
||||||
|
$emailLabel = $scope === 'tenant' ? 'Login-E-Mail' : 'Admin-E-Mail';
|
||||||
|
require __DIR__ . '/../partials/layout-top.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section class="page-head">
|
||||||
|
<span class="eyebrow">Passwort vergessen</span>
|
||||||
|
<h1>Reset-Anfrage vorbereiten</h1>
|
||||||
|
<p>
|
||||||
|
<?= e($scope === 'platform'
|
||||||
|
? 'Diese Seite sammelt die Anfrage fuer einen Plattform-Reset.'
|
||||||
|
: 'Diese Seite sammelt die Anfrage fuer den Standort-Reset von ' . $tenantName . '.') ?>
|
||||||
|
Im Pilotbetrieb kann die Bestaetigung noch manuell begleitet werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<form class="card form-card" method="post" action="<?= e(current_path()) ?>">
|
||||||
|
<?= csrf_field($csrf) ?>
|
||||||
|
<input type="hidden" name="scope" value="<?= e($scope) ?>">
|
||||||
|
<?php if ($tenantSlug !== ''): ?>
|
||||||
|
<input type="hidden" name="tenant_slug" value="<?= e($tenantSlug) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($scope === 'tenant' && $tenantName !== ''): ?>
|
||||||
|
<input type="hidden" name="tenant_name" value="<?= e($tenantName) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<label>
|
||||||
|
<span><?= e($emailLabel) ?></span>
|
||||||
|
<input type="email" name="email" value="<?= e((string) old('email')) ?>" required>
|
||||||
|
</label>
|
||||||
|
<?php if ($scope !== 'platform' && $tenantSlug === ''): ?>
|
||||||
|
<label>
|
||||||
|
<span>Standort-Slug</span>
|
||||||
|
<input type="text" name="tenant_slug" value="<?= e((string) old('tenant_slug')) ?>" placeholder="mein-team" required>
|
||||||
|
</label>
|
||||||
|
<?php endif; ?>
|
||||||
|
<button class="button button-primary" type="submit">Reset-Link anfordern</button>
|
||||||
|
<p class="muted">Nach dem Versand soll der Link wieder auf diese Strecke zurueckfuehren und danach zum passenden Login.</p>
|
||||||
|
<p><a class="text-link" href="<?= e($loginUrl) ?>">Zurueck zum Login</a></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside class="card">
|
||||||
|
<span class="eyebrow">Pilotablauf</span>
|
||||||
|
<ul class="stack-list">
|
||||||
|
<li>
|
||||||
|
<strong>1. E-Mail pruefen</strong>
|
||||||
|
<small>Die Anfrage sollte immer ueber die bekannte Login-Adresse laufen.</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>2. Reset bestaetigen</strong>
|
||||||
|
<small>Im Pilot kann diese Freigabe vorerst noch manuell durch Plattform- oder Standortteam begleitet werden.</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>3. Neues Passwort setzen</strong>
|
||||||
|
<small>Danach geht es ueber die Confirm-Seite direkt zurueck in den passenden Login.</small>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
@@ -2,35 +2,35 @@
|
|||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<span class="eyebrow">Von der Papierliste zur produktiven SaaS</span>
|
<span class="eyebrow">Pilotbetrieb fuer Teams und Vereine</span>
|
||||||
<h1>Kaffeeliste, digitale Strichliste und RFID-Roadmap in einem System.</h1>
|
<h1>Die digitale Kaffeeliste ist bereit fuer echte Pilotstandorte, nicht fuer leere Demo-Versprechen.</h1>
|
||||||
<p class="lead">Die Anwendung verbindet klassische Kaffeekasse, digitale Selbstbuchung und spaetere Geraeteintegration. Mandanten registrieren sich selbst, verwalten Mitglieder, Produkte, Einzahlungen und Papierlisten in einem PHP/MySQL-Stack, der auf Netcup-Webspace laeuft.</p>
|
<p class="lead">Aktuell wird der Ablauf mit ausgewaehlten Teams verfeinert: Pilotmandanten koennen sich registrieren, Mitglieder fuer die Selbstbuchung anlegen und Backoffice-Prozesse fuer Papierlisten, Einzahlungen und spaetere RFID-Anbindung realistisch testen.</p>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<?php if ($setupComplete): ?>
|
<?php if ($setupComplete): ?>
|
||||||
<a class="button button-primary" href="/register">Mandant starten</a>
|
<a class="button button-primary" href="/register">Pilotmandant anlegen</a>
|
||||||
<a class="button button-secondary" href="/admin/login">Plattform betreten</a>
|
<a class="button button-secondary" href="/admin/login">Plattform-Login</a>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<a class="button button-primary" href="/install">Installation starten</a>
|
<a class="button button-primary" href="/install">Installation starten</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<ul class="metric-row">
|
<ul class="metric-row">
|
||||||
<li><strong>1</strong><span>gemeinsames Ledger fuer digital, Papier und RFID</span></li>
|
<li><strong>1</strong><span>gemeinsames Ledger fuer Selbstbuchung, Papier und spaetere RFID-Events</span></li>
|
||||||
<li><strong>3</strong><span>Pakete fuer Vermarktung und Rollout</span></li>
|
<li><strong>Pilot</strong><span>klare Trennung zwischen Member-Self-Service und Standort-Backoffice</span></li>
|
||||||
<li><strong>100%</strong><span>Webspace-kompatibler PHP/MySQL-Betrieb</span></li>
|
<li><strong>100%</strong><span>PHP/MySQL-Betrieb fuer klassisches Webhosting</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<aside class="hero-card">
|
<aside class="hero-card">
|
||||||
<h2>Produktive Ausrichtung</h2>
|
<h2>Worauf der Pilot gerade abzielt</h2>
|
||||||
<ul class="feature-list">
|
<ul class="feature-list">
|
||||||
<li>Mandanten-Self-Service mit Owner-Onboarding</li>
|
<li>Owner-Onboarding mit sofort nutzbarem Testmandanten</li>
|
||||||
<li>Digitale Buchungen fuer Tablet, Browser und Kiosk</li>
|
<li>Mitglieder-Logins fuer schlanke Selbstbuchung statt Volladmin-Anmutung</li>
|
||||||
<li>Papierlisten als Entwurf, Nacherfassung und Ledger-Posting</li>
|
<li>Papierlisten als Entwurf, Nacherfassung und sauberes Ledger-Posting</li>
|
||||||
<li>Einzahlungen, Salden und Audit-Trail pro Mandant</li>
|
<li>Einzahlungen, Salden und Audit-Trail fuer das Standortteam</li>
|
||||||
<li>RFID-Devices, Karten und Event-Inbox fuer die spaetere Automatisierung</li>
|
<li>RFID-Devices, Karten und Event-Inbox fuer spaetere Automatisierung</li>
|
||||||
</ul>
|
</ul>
|
||||||
<?php if (is_array($stats)): ?>
|
<?php if (is_array($stats)): ?>
|
||||||
<div class="stats-panel">
|
<div class="stats-panel">
|
||||||
<div><strong><?= e((string) $stats['tenants']) ?></strong><span>Mandanten in der Datenbank</span></div>
|
<div><strong><?= e((string) $stats['tenants']) ?></strong><span>Pilotmandanten in der Datenbank</span></div>
|
||||||
<div><strong><?= e((string) $stats['members']) ?></strong><span>Mitglieder</span></div>
|
<div><strong><?= e((string) $stats['members']) ?></strong><span>Mitglieder</span></div>
|
||||||
<div><strong><?= e((string) $stats['events']) ?></strong><span>Buchungsvorfaelle</span></div>
|
<div><strong><?= e((string) $stats['events']) ?></strong><span>Buchungsvorfaelle</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,53 +40,53 @@
|
|||||||
|
|
||||||
<section class="content-grid">
|
<section class="content-grid">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<span class="eyebrow">Produkt</span>
|
<span class="eyebrow">Heute stabil</span>
|
||||||
<h2>Eine Buchungslogik, mehrere Erfassungswege</h2>
|
<h2>Eine Buchungslogik fuer reale Nutzung</h2>
|
||||||
<p>Jede Kaffee- oder Tee-Buchung landet im selben Ledger. Ob jemand digital klickt, ob ein Admin eine Papierliste erfasst oder ob spaeter ein RFID-Leser Events einspeist: Salden, Historie und Auswertungen bleiben konsistent.</p>
|
<p>Jede Kaffee- oder Tee-Buchung landet im selben Ledger. Ob jemand digital bucht, ob das Standortteam eine Papierliste erfasst oder spaeter ein RFID-Leser Events einspeist: Salden, Historie und Auswertungen bleiben konsistent.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<span class="eyebrow">Marketing</span>
|
<span class="eyebrow">Pilotfokus</span>
|
||||||
<h2>Klare Positionierung fuer Teams, Vereine und Coworking</h2>
|
<h2>Self-Service fuer Mitglieder, Backoffice fuers Standortteam</h2>
|
||||||
<p>Die Startseite spricht gezielt Organisationen an, die zwischen analoger Kaffeekasse und digitaler Selbstbedienung stehen. Das senkt Erklaerungsaufwand und macht den Upgrade-Pfad zu RFID nachvollziehbar.</p>
|
<p>Der Pilot trennt bewusst zwischen einfacher Selbstbuchung und administrativen Seiten. Dadurch wirkt der Zugang fuer Mitglieder alltagstauglich, waehrend Produkte, Einzahlungen und Korrekturen beim Standortteam bleiben.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<span class="eyebrow">Betrieb</span>
|
<span class="eyebrow">Rahmen</span>
|
||||||
<h2>Netcup-tauglich statt hyperskalierter Overkill</h2>
|
<h2>Ehrlicher Rollout statt Schein-Automatisierung</h2>
|
||||||
<p>Pfadbasierte Mandanten, gemeinsamer MySQL-Kern, Cron statt Worker und ein sauberer Webroot auf `public/` sind genau auf klassisches Webhosting zugeschnitten.</p>
|
<p>Reset-Flow, Exporte und Korrekturhilfen werden gerade an echten Pilotablaeufen geschliffen. Gleichzeitig bleibt der technische Kern schlank genug fuer klassisches Webhosting mit PHP, MySQL und Webroot auf `public/`.</p>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="plans">
|
<section class="plans">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<span class="eyebrow">Pakete</span>
|
<span class="eyebrow">Pilotstufen</span>
|
||||||
<h2>Preismodelle fuer die Vermarktung</h2>
|
<h2>Die Startkonfiguration im Pilot, nicht die finale Vermarktung</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="plan-grid">
|
<div class="plan-grid">
|
||||||
<article class="plan-card">
|
<article class="plan-card">
|
||||||
<h3>Starter</h3>
|
<h3>Starter</h3>
|
||||||
<p>Fuer kleine Teams und Vereine mit digitaler Buchung und einfacher Kaffeekasse.</p>
|
<p>Fuer kleine Teams mit digitaler Buchung und moeglichst wenig Abstimmungsaufwand im Alltag.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Mitglieder, Produkte, digitale Buchungen</li>
|
<li>Mitglieder, Produkte und Selbstbuchung</li>
|
||||||
<li>Einzahlungen und Salden</li>
|
<li>Schlanke Navigation fuer Member-Logins</li>
|
||||||
<li>Self-Service-Registrierung</li>
|
<li>Schneller Einstieg fuer den ersten Pilotstandort</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article class="plan-card highlighted">
|
<article class="plan-card highlighted">
|
||||||
<h3>Team</h3>
|
<h3>Team</h3>
|
||||||
<p>Fuer Organisationen, die Papierlisten und digitale Prozesse parallel fahren.</p>
|
<p>Fuer Organisationen, die Papierlisten und digitale Prozesse im Pilot parallel fahren.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Papierlisten-Workflow mit Nacherfassung</li>
|
<li>Papierlisten-Workflow mit Nacherfassung</li>
|
||||||
<li>Plattform-Dashboard und Audit-Trail</li>
|
<li>Backoffice fuer Einzahlungen und Korrekturen</li>
|
||||||
<li>Vorbereitung fuer Standortgeraete</li>
|
<li>Vorbereitung fuer spaetere Export- und Supportpfade</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article class="plan-card">
|
<article class="plan-card">
|
||||||
<h3>Business</h3>
|
<h3>Business</h3>
|
||||||
<p>Fuer Standorte mit RFID-Roadmap, Betriebsanforderungen und spaeterem White-Labeling.</p>
|
<p>Fuer Standorte mit RFID-Roadmap, hoeheren Betriebsanforderungen und fruehem Integrationsbedarf.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>RFID-Devices, Karten und Intake-API</li>
|
<li>RFID-Devices, Karten und Intake-API</li>
|
||||||
<li>Release-, Backup- und Deployment-Rahmen</li>
|
<li>Release-, Backup- und Deployment-Rahmen</li>
|
||||||
<li>Vorlage fuer spaetere Integrationen</li>
|
<li>Frueher Blick auf Betriebs- und Supportprozesse</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,14 +94,15 @@
|
|||||||
|
|
||||||
<section class="timeline card">
|
<section class="timeline card">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<span class="eyebrow">Roadmap</span>
|
<span class="eyebrow">Pilotablauf</span>
|
||||||
<h2>Sauberer Pfad in die Produktion</h2>
|
<h2>So startet ein neuer Standort realistisch</h2>
|
||||||
</div>
|
</div>
|
||||||
<ol class="step-list">
|
<ol class="step-list">
|
||||||
<li>Plattform installieren und ersten Admin anlegen.</li>
|
<li>Plattform installieren und ersten Admin anlegen.</li>
|
||||||
<li>Mandant registrieren, Owner-Onboarding abschliessen und Produkte vorbelegen.</li>
|
<li>Pilotmandant registrieren, Owner-Onboarding abschliessen und Produkte vorbelegen.</li>
|
||||||
|
<li>Mitglieder fuer Selbstbuchung anlegen und Standort-Backoffice abstimmen.</li>
|
||||||
<li>Digitale Buchungen und Papierlisten parallel im Team einsetzen.</li>
|
<li>Digitale Buchungen und Papierlisten parallel im Team einsetzen.</li>
|
||||||
<li>Mit RFID-Geraeten spaeter ueber die Event-Inbox anbinden, ohne das Kernledger umzubauen.</li>
|
<li>RFID-Geraete spaeter ueber die Event-Inbox anbinden, ohne das Kernledger umzubauen.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<span class="eyebrow">Self-Service</span>
|
<span class="eyebrow">Pilot-Onboarding</span>
|
||||||
<h1>Mandant registrieren</h1>
|
<h1>Pilotmandant registrieren</h1>
|
||||||
<p>Der Owner legt den ersten Standort an und bekommt automatisch die Grundbausteine fuer digitale Buchung, Papierliste und spaetere RFID-Anbindung.</p>
|
<p>Dieses Formular legt einen echten Teststandort an. Bitte nur verwenden, wenn Organisation, Ansprechpartner und Startpreis fuer den Pilotbetrieb abgestimmt sind.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="two-column">
|
<div class="two-column">
|
||||||
<form class="card form-card" method="post" action="/register">
|
<form class="card form-card" method="post" action="/register">
|
||||||
<?= csrf_field($csrf) ?>
|
<?= csrf_field($csrf) ?>
|
||||||
<label>
|
<label>
|
||||||
<span>Organisationsname</span>
|
<span>Team- / Standortname</span>
|
||||||
<input type="text" name="tenant_name" value="<?= e((string) old('tenant_name')) ?>" required>
|
<input type="text" name="tenant_name" value="<?= e((string) old('tenant_name')) ?>" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Kurzname / URL-Slug</span>
|
<span>Kurzname fuer die Login-URL</span>
|
||||||
<input type="text" name="tenant_slug" value="<?= e((string) old('tenant_slug')) ?>" placeholder="mein-team">
|
<input type="text" name="tenant_slug" value="<?= e((string) old('tenant_slug')) ?>" placeholder="mein-team">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Paket</span>
|
<span>Pilotstufe</span>
|
||||||
<select name="plan">
|
<select name="plan">
|
||||||
<option value="starter">Starter</option>
|
<option value="starter">Starter</option>
|
||||||
<option value="team" selected>Team</option>
|
<option value="team" selected>Team</option>
|
||||||
@@ -26,33 +26,33 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Standardpreis pro Kaffee in EUR</span>
|
<span>Startpreis pro Kaffee in EUR</span>
|
||||||
<input type="number" name="default_price_eur" min="0.50" step="0.05" value="<?= e((string) old('default_price_eur', '1.20')) ?>">
|
<input type="number" name="default_price_eur" min="0.50" step="0.05" value="<?= e((string) old('default_price_eur', '1.20')) ?>">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Owner-Name</span>
|
<span>Ansprechpartner / Owner</span>
|
||||||
<input type="text" name="owner_name" value="<?= e((string) old('owner_name')) ?>" required>
|
<input type="text" name="owner_name" value="<?= e((string) old('owner_name')) ?>" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Owner-E-Mail</span>
|
<span>Login-E-Mail fuer den Owner</span>
|
||||||
<input type="email" name="owner_email" value="<?= e((string) old('owner_email')) ?>" required>
|
<input type="email" name="owner_email" value="<?= e((string) old('owner_email')) ?>" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Owner-Passwort</span>
|
<span>Startpasswort fuer den Owner</span>
|
||||||
<input type="password" name="owner_password" minlength="12" required>
|
<input type="password" name="owner_password" minlength="12" required>
|
||||||
</label>
|
</label>
|
||||||
<button class="button button-primary" type="submit">Mandant anlegen</button>
|
<button class="button button-primary" type="submit">Pilotmandant anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside class="card">
|
<aside class="card">
|
||||||
<span class="eyebrow">Was automatisch entsteht</span>
|
<span class="eyebrow">Vor dem Absenden</span>
|
||||||
<ul class="feature-list">
|
<ul class="feature-list">
|
||||||
<li>Tenant mit Owner-Rolle</li>
|
<li>Die Registrierung erzeugt sofort einen nutzbaren Testmandanten</li>
|
||||||
<li>Systemquellen fuer digital, Papier, Backoffice und RFID</li>
|
<li>Owner-Zugang und erstes Mitglied werden automatisch angelegt</li>
|
||||||
<li>Standardprodukte Kaffee, Espresso und Tee</li>
|
<li>Systemquellen fuer digital, Papier, Backoffice und RFID stehen direkt bereit</li>
|
||||||
<li>Voreinstellung fuer Self-Service und Papierlisten</li>
|
<li>Die Self-Service-Navigation fuer Mitglieder bleibt spaeter schlanker als das Admin-Backoffice</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="muted">Damit ist die Vermarktung sauber: Interessenten landen nicht in einem Technikgeruest, sondern in einem sofort nutzbaren Grundaufbau.</p>
|
<p class="muted">Wenn ihr erst unverbindlich pruefen wollt, startet lieber mit einem klar benannten Pilotteam statt mit Fantasiedaten. Das macht spaetere Login-, Reset- und Supportablaeufe deutlich sauberer.</p>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ $flashError = flash($session, 'error');
|
|||||||
$authState = $authState ?? $session->get('auth');
|
$authState = $authState ?? $session->get('auth');
|
||||||
$tenant = $tenant ?? null;
|
$tenant = $tenant ?? null;
|
||||||
$currentPath = current_path();
|
$currentPath = current_path();
|
||||||
|
$tenantSlug = is_array($tenant) && isset($tenant['slug']) ? (string) $tenant['slug'] : '';
|
||||||
|
$tenantRole = is_array($authState) ? (string) ($authState['tenant_role'] ?? '') : '';
|
||||||
|
$hasTenantAccess = $tenantSlug !== ''
|
||||||
|
&& is_array($authState)
|
||||||
|
&& (($authState['tenant_slug'] ?? null) === $tenantSlug);
|
||||||
|
$isTenantAdmin = in_array($tenantRole, ['owner', 'admin'], true);
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
@@ -25,18 +31,25 @@ $currentPath = current_path();
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="top-nav">
|
<nav class="top-nav">
|
||||||
<?php if (is_array($tenant) && isset($tenant['slug'])): ?>
|
<?php if ($hasTenantAccess): ?>
|
||||||
<a href="<?= e(tenant_url($tenant['slug'])) ?>" class="<?= $currentPath === tenant_url($tenant['slug']) ? 'active' : '' ?>">Dashboard</a>
|
<a href="<?= e(tenant_url($tenantSlug)) ?>" class="<?= $currentPath === tenant_url($tenantSlug) ? 'active' : '' ?>">Uebersicht</a>
|
||||||
<a href="<?= e(tenant_url($tenant['slug'], 'members')) ?>" class="<?= str_contains($currentPath, '/members') ? 'active' : '' ?>">Mitglieder</a>
|
<a href="<?= e(tenant_url($tenantSlug, 'bookings')) ?>" class="<?= str_contains($currentPath, '/bookings') ? 'active' : '' ?>">Buchen</a>
|
||||||
<a href="<?= e(tenant_url($tenant['slug'], 'products')) ?>" class="<?= str_contains($currentPath, '/products') ? 'active' : '' ?>">Produkte</a>
|
<?php if ($isTenantAdmin): ?>
|
||||||
<a href="<?= e(tenant_url($tenant['slug'], 'bookings')) ?>" class="<?= str_contains($currentPath, '/bookings') ? 'active' : '' ?>">Digital</a>
|
<a href="<?= e(tenant_url($tenantSlug, 'members')) ?>" class="<?= str_contains($currentPath, '/members') ? 'active' : '' ?>">Mitglieder</a>
|
||||||
<a href="<?= e(tenant_url($tenant['slug'], 'payments')) ?>" class="<?= str_contains($currentPath, '/payments') ? 'active' : '' ?>">Einzahlungen</a>
|
<a href="<?= e(tenant_url($tenantSlug, 'products')) ?>" class="<?= str_contains($currentPath, '/products') ? 'active' : '' ?>">Produkte</a>
|
||||||
<a href="<?= e(tenant_url($tenant['slug'], 'paper-sheets')) ?>" class="<?= str_contains($currentPath, '/paper-sheets') ? 'active' : '' ?>">Papier</a>
|
<a href="<?= e(tenant_url($tenantSlug, 'payments')) ?>" class="<?= str_contains($currentPath, '/payments') ? 'active' : '' ?>">Einzahlungen</a>
|
||||||
<a href="<?= e(tenant_url($tenant['slug'], 'rfid')) ?>" class="<?= str_contains($currentPath, '/rfid') ? 'active' : '' ?>">RFID</a>
|
<a href="<?= e(tenant_url($tenantSlug, 'paper-sheets')) ?>" class="<?= str_contains($currentPath, '/paper-sheets') ? 'active' : '' ?>">Papierlisten</a>
|
||||||
|
<a href="<?= e(tenant_url($tenantSlug, 'rfid')) ?>" class="<?= str_contains($currentPath, '/rfid') ? 'active' : '' ?>">RFID</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php elseif ($tenantSlug !== ''): ?>
|
||||||
|
<a href="/" class="<?= $currentPath === '/' ? 'active' : '' ?>">Start</a>
|
||||||
|
<a href="/register" class="<?= $currentPath === '/register' ? 'active' : '' ?>">Pilot registrieren</a>
|
||||||
|
<a href="<?= e(tenant_url($tenantSlug, 'login')) ?>" class="<?= $currentPath === tenant_url($tenantSlug, 'login') ? 'active' : '' ?>">Standort-Login</a>
|
||||||
|
<a href="/admin/login" class="<?= str_starts_with($currentPath, '/admin') ? 'active' : '' ?>">Plattform</a>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<a href="/">Start</a>
|
<a href="/" class="<?= $currentPath === '/' ? 'active' : '' ?>">Start</a>
|
||||||
<a href="/register">Registrieren</a>
|
<a href="/register" class="<?= $currentPath === '/register' ? 'active' : '' ?>">Pilot registrieren</a>
|
||||||
<a href="/admin/login">Plattform</a>
|
<a href="/admin/login" class="<?= str_starts_with($currentPath, '/admin') ? 'active' : '' ?>">Plattform</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,44 @@
|
|||||||
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
<?php
|
||||||
|
$tenantRole = (string) ($authState['tenant_role'] ?? 'member');
|
||||||
|
$isSelfService = $tenantRole === 'member';
|
||||||
|
$selectedMemberId = (string) old('member_id');
|
||||||
|
$selectedProductId = (string) old('product_id');
|
||||||
|
$selectedSourceCode = (string) old('source_code', 'digital_self');
|
||||||
|
require __DIR__ . '/../partials/layout-top.php';
|
||||||
|
?>
|
||||||
|
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<span class="eyebrow">Selbstbedienung</span>
|
<span class="eyebrow"><?= e($isSelfService ? 'Selbstbuchung' : 'Digitale Erfassung') ?></span>
|
||||||
<h1>Digitale Buchungen</h1>
|
<h1><?= e($isSelfService ? 'Getraenke buchen' : 'Digitale Buchungen') ?></h1>
|
||||||
<p>Dies ist der produktive Kern fuer Browser, Tablet und spaetere Kiosk-Workflows.</p>
|
<?php if ($isSelfService): ?>
|
||||||
|
<p>Diese Seite ist auf taegliche Member-Self-Service-Buchungen zugeschnitten. Korrekturen, Sammelbuchungen und Exporte bleiben im Pilot vorerst beim Standortteam.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p>Browser, Tablet und Kiosk landen im selben Ledger. Das Standortteam kann hier Selbstbuchung und Backoffice-Buchungen sichtbar trennen.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<span class="eyebrow">Export & Korrekturen</span>
|
||||||
|
<p class="muted">
|
||||||
|
<?= e($isSelfService
|
||||||
|
? 'Hier ist bewusst Platz fuer persoenliche Exporte und klare Korrekturhinweise reserviert, ohne heute schon neue Backend-Logik vorwegzunehmen.'
|
||||||
|
: 'Hier ist bewusst Platz fuer CSV-Exporte, Sammelkorrekturen und Rueckfragen reserviert, ohne heute schon neue Controllerpfade vorwegzunehmen.') ?>
|
||||||
|
</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Download-Link fuer Buchungs-Export: Platzhalter fuer spaetere CSV- oder Monatsansicht</li>
|
||||||
|
<li>Storno- und Korrekturhinweise: Platzhalter fuer kuenftige Bedienhilfe direkt am Workflow</li>
|
||||||
|
<li><?= e($isSelfService ? 'Bis dahin bitte im Pilot nur den eigenen Namen waehlen; persoenliche Vorauswahl folgt.' : 'Sammelkorrekturen und Nachbuchungen bleiben vorerst beim Standortteam.') ?></li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="two-column">
|
<div class="two-column">
|
||||||
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'bookings')) ?>">
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'bookings')) ?>">
|
||||||
<?= csrf_field($csrf) ?>
|
<?= csrf_field($csrf) ?>
|
||||||
<label>
|
<label>
|
||||||
<span>Mitglied</span>
|
<span><?= e($isSelfService ? 'Dein Mitgliedseintrag' : 'Mitglied') ?></span>
|
||||||
<select name="member_id" required>
|
<select name="member_id" required>
|
||||||
<?php foreach ($members as $member): ?>
|
<?php foreach ($members as $member): ?>
|
||||||
<option value="<?= e((string) $member['id']) ?>"><?= e($member['display_name']) ?></option>
|
<option value="<?= e((string) $member['id']) ?>" <?= $selectedMemberId === (string) $member['id'] ? 'selected' : '' ?>><?= e($member['display_name']) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -21,17 +46,21 @@
|
|||||||
<span>Produkt</span>
|
<span>Produkt</span>
|
||||||
<select name="product_id" required>
|
<select name="product_id" required>
|
||||||
<?php foreach ($products as $product): ?>
|
<?php foreach ($products as $product): ?>
|
||||||
<option value="<?= e((string) $product['id']) ?>"><?= e($product['name']) ?> (<?= e(money_from_cents((int) $product['price_cents'])) ?>)</option>
|
<option value="<?= e((string) $product['id']) ?>" <?= $selectedProductId === (string) $product['id'] ? 'selected' : '' ?>><?= e($product['name']) ?> (<?= e(money_from_cents((int) $product['price_cents'])) ?>)</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<?php if ($isSelfService): ?>
|
||||||
<span>Quelle</span>
|
<input type="hidden" name="source_code" value="digital_self">
|
||||||
<select name="source_code">
|
<?php else: ?>
|
||||||
<option value="digital_self">Digitale Selbstbuchung</option>
|
<label>
|
||||||
<option value="admin_backoffice">Backoffice-Buchung</option>
|
<span>Quelle</span>
|
||||||
</select>
|
<select name="source_code">
|
||||||
</label>
|
<option value="digital_self" <?= $selectedSourceCode === 'digital_self' ? 'selected' : '' ?>>Digitale Selbstbuchung</option>
|
||||||
|
<option value="admin_backoffice" <?= $selectedSourceCode === 'admin_backoffice' ? 'selected' : '' ?>>Backoffice-Buchung</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<?php endif; ?>
|
||||||
<label>
|
<label>
|
||||||
<span>Menge</span>
|
<span>Menge</span>
|
||||||
<input type="number" name="quantity" min="0.25" step="0.25" value="<?= e((string) old('quantity', '1')) ?>" required>
|
<input type="number" name="quantity" min="0.25" step="0.25" value="<?= e((string) old('quantity', '1')) ?>" required>
|
||||||
@@ -44,33 +73,61 @@
|
|||||||
<span>Notiz</span>
|
<span>Notiz</span>
|
||||||
<input type="text" name="notes" value="<?= e((string) old('notes')) ?>">
|
<input type="text" name="notes" value="<?= e((string) old('notes')) ?>">
|
||||||
</label>
|
</label>
|
||||||
<button class="button button-primary" type="submit">Buchung speichern</button>
|
<button class="button button-primary" type="submit"><?= e($isSelfService ? 'Jetzt buchen' : 'Buchung speichern') ?></button>
|
||||||
|
<?php if ($isSelfService): ?>
|
||||||
|
<p class="muted">Wenn du mehrere Namen siehst, nutze im Pilot bitte nur deinen eigenen Eintrag. Falsche Buchungen korrigiert das Standortteam.</p>
|
||||||
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="table-card">
|
<?php if ($isSelfService): ?>
|
||||||
<table>
|
<section class="card">
|
||||||
<thead>
|
<span class="eyebrow">Pilot-Hinweis</span>
|
||||||
<tr>
|
<ul class="stack-list">
|
||||||
<th>Zeit</th>
|
<li>
|
||||||
<th>Mitglied</th>
|
<strong>Eigene Historie</strong>
|
||||||
<th>Produkt</th>
|
<small>Ein persoenlicher Verlauf wird spaeter hier eingeblendet.</small>
|
||||||
<th>Quelle</th>
|
</li>
|
||||||
<th>Betrag</th>
|
<li>
|
||||||
</tr>
|
<strong>Korrekturen</strong>
|
||||||
</thead>
|
<small>Falsche Buchungen bitte direkt an das Standortteam melden.</small>
|
||||||
<tbody>
|
</li>
|
||||||
<?php foreach ($bookings as $booking): ?>
|
<li>
|
||||||
|
<strong>Exporte</strong>
|
||||||
|
<small>Downloads und Monatsauszuege folgen nach weiterem Pilotfeedback.</small>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<?php else: ?>
|
||||||
|
<section class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= e($booking['recorded_at']) ?></td>
|
<th>Zeit</th>
|
||||||
<td><?= e($booking['member_name']) ?></td>
|
<th>Mitglied</th>
|
||||||
<td><?= e($booking['product_name']) ?></td>
|
<th>Produkt</th>
|
||||||
<td><?= e($booking['source_name']) ?></td>
|
<th>Quelle</th>
|
||||||
<td><?= e(money_from_cents((int) $booking['total_cents'])) ?></td>
|
<th>Betrag</th>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
<?php if ($bookings === []): ?>
|
||||||
</section>
|
<tr>
|
||||||
|
<td colspan="5">Noch keine Buchungen erfasst.</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($bookings as $booking): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= e($booking['recorded_at']) ?></td>
|
||||||
|
<td><?= e($booking['member_name']) ?></td>
|
||||||
|
<td><?= e($booking['product_name']) ?></td>
|
||||||
|
<td><?= e($booking['source_name']) ?></td>
|
||||||
|
<td><?= e(money_from_cents((int) $booking['total_cents'])) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
<?php
|
||||||
|
$tenantRole = (string) ($authState['tenant_role'] ?? 'member');
|
||||||
|
$isSelfService = $tenantRole === 'member';
|
||||||
|
require __DIR__ . '/../partials/layout-top.php';
|
||||||
|
?>
|
||||||
|
|
||||||
<section class="page-head inline-head">
|
<section class="page-head inline-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow"><?= e(ucfirst($tenant['plan'])) ?> Paket</span>
|
<span class="eyebrow"><?= e($isSelfService ? 'Member-Self-Service' : ucfirst((string) $tenant['plan']) . ' Paket') ?></span>
|
||||||
<h1><?= e($tenant['name']) ?></h1>
|
<h1><?= e($tenant['name']) ?></h1>
|
||||||
<p>Rolle: <?= e((string) ($authState['tenant_role'] ?? 'member')) ?>. Hier laufen digitale Buchungen, Papierlisten und Betriebszahlen zusammen.</p>
|
<?php if ($isSelfService): ?>
|
||||||
|
<p>Rolle: <?= e($tenantRole) ?>. Dieser Zugang ist fuer die taegliche Selbstbuchung gedacht; Stammdaten, Saldenpflege und Korrekturen bleiben beim Standortteam.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p>Rolle: <?= e($tenantRole) ?>. Hier laufen digitale Buchungen, Backoffice-Korrekturen und Standortzahlen zusammen.</p>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="<?= e(tenant_url($tenant['slug'], 'logout')) ?>">
|
<form method="post" action="<?= e(tenant_url($tenant['slug'], 'logout')) ?>">
|
||||||
<?= csrf_field($csrf) ?>
|
<?= csrf_field($csrf) ?>
|
||||||
@@ -12,49 +20,92 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="metric-cards">
|
<?php if ($isSelfService): ?>
|
||||||
<article class="metric-card">
|
<div class="two-column">
|
||||||
<span>Mitglieder</span>
|
<section class="card">
|
||||||
<strong><?= e((string) $summary['member_count']) ?></strong>
|
<span class="eyebrow">Was du hier tun kannst</span>
|
||||||
</article>
|
<ul class="feature-list">
|
||||||
<article class="metric-card">
|
<li>Digitale Buchungen fuer deinen Standort erfassen</li>
|
||||||
<span>Produkte</span>
|
<li>Produktpreise vor dem Buchen einsehen</li>
|
||||||
<strong><?= e((string) $summary['product_count']) ?></strong>
|
<li>Passwort-Reset oder Buchungskorrekturen ueber das Standortteam anstossen</li>
|
||||||
</article>
|
</ul>
|
||||||
<article class="metric-card">
|
<p class="muted">Die Navigation zeigt hier bewusst nur Self-Service-Seiten, damit dieser Zugang nicht wie Volladministration wirkt.</p>
|
||||||
<span>Papierlisten</span>
|
</section>
|
||||||
<strong><?= e((string) $summary['paper_sheet_count']) ?></strong>
|
<section class="card">
|
||||||
</article>
|
<span class="eyebrow">Pilotbetrieb</span>
|
||||||
<article class="metric-card">
|
<ul class="stack-list">
|
||||||
<span>Offene Salden</span>
|
<li>
|
||||||
<strong><?= e(money_from_cents((int) $summary['open_balance_cents'])) ?></strong>
|
<strong>Stammdaten und Mitglieder</strong>
|
||||||
</article>
|
<small>Pflegt weiterhin das Standortteam im Backoffice.</small>
|
||||||
</section>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Einzahlungen und Papierlisten</strong>
|
||||||
|
<small>Werden separat verbucht und bei Bedarf dort korrigiert.</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Exporte und persoenliche Historie</strong>
|
||||||
|
<small>Werden im Pilot noch schrittweise nachgezogen.</small>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<section class="metric-cards">
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Mitglieder</span>
|
||||||
|
<strong><?= e((string) $summary['member_count']) ?></strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Produkte</span>
|
||||||
|
<strong><?= e((string) $summary['product_count']) ?></strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Papierlisten</span>
|
||||||
|
<strong><?= e((string) $summary['paper_sheet_count']) ?></strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Offene Salden</span>
|
||||||
|
<strong><?= e(money_from_cents((int) $summary['open_balance_cents'])) ?></strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="two-column">
|
<div class="two-column">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<span class="eyebrow">Letzte Buchungen</span>
|
<span class="eyebrow">Letzte Buchungen</span>
|
||||||
<ul class="stack-list">
|
<ul class="stack-list">
|
||||||
<?php foreach ($latestEvents as $event): ?>
|
<?php if ($latestEvents === []): ?>
|
||||||
<li>
|
<li>
|
||||||
<strong><?= e($event['member_name']) ?></strong>
|
<strong>Noch keine Buchungen</strong>
|
||||||
<span><?= e($event['product_name']) ?> via <?= e($event['source_name']) ?></span>
|
<small>Die letzten Ereignisse erscheinen hier, sobald der Pilotstandort aktiv bucht.</small>
|
||||||
<small><?= e(money_from_cents((int) $event['total_cents'])) ?> am <?= e($event['recorded_at']) ?></small>
|
</li>
|
||||||
</li>
|
<?php endif; ?>
|
||||||
<?php endforeach; ?>
|
<?php foreach ($latestEvents as $event): ?>
|
||||||
</ul>
|
<li>
|
||||||
</section>
|
<strong><?= e($event['member_name']) ?></strong>
|
||||||
<section class="card">
|
<span><?= e($event['product_name']) ?> via <?= e($event['source_name']) ?></span>
|
||||||
<span class="eyebrow">Hohe Salden</span>
|
<small><?= e(money_from_cents((int) $event['total_cents'])) ?> am <?= e($event['recorded_at']) ?></small>
|
||||||
<ul class="stack-list">
|
</li>
|
||||||
<?php foreach ($memberBalances as $balance): ?>
|
<?php endforeach; ?>
|
||||||
<li>
|
</ul>
|
||||||
<strong><?= e($balance['display_name']) ?></strong>
|
</section>
|
||||||
<small><?= e(money_from_cents((int) $balance['balance_cents'])) ?></small>
|
<section class="card">
|
||||||
</li>
|
<span class="eyebrow">Hohe Salden</span>
|
||||||
<?php endforeach; ?>
|
<ul class="stack-list">
|
||||||
</ul>
|
<?php if ($memberBalances === []): ?>
|
||||||
</section>
|
<li>
|
||||||
</div>
|
<strong>Noch keine Salden</strong>
|
||||||
|
<small>Sobald Buchungen oder Einzahlungen vorliegen, erscheint hier die Uebersicht.</small>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($memberBalances as $balance): ?>
|
||||||
|
<li>
|
||||||
|
<strong><?= e($balance['display_name']) ?></strong>
|
||||||
|
<small><?= e(money_from_cents((int) $balance['balance_cents'])) ?></small>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
<?php
|
||||||
|
$resetRequestUrl = '/password/forgot?scope=tenant&tenantSlug=' . rawurlencode((string) $tenant['slug'])
|
||||||
|
. '&tenantName=' . rawurlencode((string) $tenant['name']);
|
||||||
|
require __DIR__ . '/../partials/layout-top.php';
|
||||||
|
?>
|
||||||
|
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<span class="eyebrow">Mandant</span>
|
<span class="eyebrow">Standortzugang</span>
|
||||||
<h1><?= e($tenant['name']) ?></h1>
|
<h1><?= e($tenant['name']) ?></h1>
|
||||||
<p>Digitale Strichliste, Papierlisten-Nacherfassung und spaetere RFID-Anbindung fuer diesen Standort.</p>
|
<p>Login fuer Owner, Standortteam und Mitglieder-Self-Service im Pilotbetrieb. Verwaltungsseiten bleiben bewusst dem Standortteam vorbehalten.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form class="card form-card slim" method="post" action="<?= e(tenant_url($tenant['slug'], 'login')) ?>">
|
<div class="two-column">
|
||||||
<?= csrf_field($csrf) ?>
|
<form class="card form-card" method="post" action="<?= e(tenant_url($tenant['slug'], 'login')) ?>">
|
||||||
<label>
|
<?= csrf_field($csrf) ?>
|
||||||
<span>E-Mail</span>
|
<label>
|
||||||
<input type="email" name="email" value="<?= e((string) old('email')) ?>" required>
|
<span>Login-E-Mail</span>
|
||||||
</label>
|
<input type="email" name="email" value="<?= e((string) old('email')) ?>" required>
|
||||||
<label>
|
</label>
|
||||||
<span>Passwort</span>
|
<label>
|
||||||
<input type="password" name="password" required>
|
<span>Passwort</span>
|
||||||
</label>
|
<input type="password" name="password" required>
|
||||||
<button class="button button-primary" type="submit">Anmelden</button>
|
</label>
|
||||||
</form>
|
<button class="button button-primary" type="submit">Standort anmelden</button>
|
||||||
|
<p class="muted">Passwort vergessen? <a class="text-link" href="<?= e($resetRequestUrl) ?>">Reset anfragen</a></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside class="card" id="reset-help">
|
||||||
|
<span class="eyebrow">Reset & Hilfe</span>
|
||||||
|
<h2>Member-Self-Service bleibt schlank</h2>
|
||||||
|
<p class="muted">Wenn ein Passwort vergessen wurde, prueft das Standortteam die Anfrage im Pilot zunaechst manuell. Die eigene Reset-Seite fuer dieses Login ist bereits vorgesehen.</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Mitglieder buchen ueber ihren Standortzugang, nicht ueber die Plattform</li>
|
||||||
|
<li>PIN und RFID sind getrennt vom Passwort-Reset</li>
|
||||||
|
<li>Korrekturen an Salden, Produkten und Papierlisten bleiben beim Standortteam</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
<?php require __DIR__ . '/../partials/layout-bottom.php'; ?>
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<span class="eyebrow">Stammdaten</span>
|
<span class="eyebrow">Standortteam</span>
|
||||||
<h1>Mitglieder verwalten</h1>
|
<h1>Mitglieder und Zugaenge</h1>
|
||||||
<p>Mitglieder koennen rein fachlich gefuehrt werden oder optional einen Login fuer die digitale Buchung erhalten.</p>
|
<p>Mitglieder koennen rein fachlich gefuehrt werden oder optional einen Login fuer die Selbstbuchung erhalten. PINs werden nach dem Speichern nicht mehr im Klartext angezeigt.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<span class="eyebrow">Export & Korrekturen</span>
|
||||||
|
<p class="muted">Hier ist bewusst Platz fuer spaetere CSV-Exporte, Sammelimporte und Statuskorrekturen reserviert, ohne dafuer heute schon neue Controllerlogik zu verlangen.</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Mitgliederliste als Export: Platzhalter fuer kuenftigen Download-Link</li>
|
||||||
|
<li>Login-, Rollen- oder PIN-Korrekturen: Platzhalter fuer Hinweise direkt am Datensatz</li>
|
||||||
|
<li>Self-Service-Logins bleiben getrennt von den Backoffice-Feldern des Standortteams</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="two-column">
|
<div class="two-column">
|
||||||
@@ -14,18 +24,19 @@
|
|||||||
<input type="text" name="display_name" value="<?= e((string) old('display_name')) ?>" required>
|
<input type="text" name="display_name" value="<?= e((string) old('display_name')) ?>" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>E-Mail fuer Login</span>
|
<span>Login-E-Mail fuer Self-Service</span>
|
||||||
<input type="email" name="email" value="<?= e((string) old('email')) ?>">
|
<input type="email" name="email" value="<?= e((string) old('email')) ?>">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Login-Passwort</span>
|
<span>Startpasswort fuer den ersten Login</span>
|
||||||
<input type="password" name="login_password" minlength="12">
|
<input type="password" name="login_password" minlength="12">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>PIN fuer Kiosk / Tablet</span>
|
<span>Optionale PIN fuer Kiosk / Tablet</span>
|
||||||
<input type="text" name="access_pin" value="<?= e((string) old('access_pin')) ?>">
|
<input type="text" name="access_pin" value="<?= e((string) old('access_pin')) ?>">
|
||||||
</label>
|
</label>
|
||||||
<button class="button button-primary" type="submit">Mitglied speichern</button>
|
<button class="button button-primary" type="submit">Mitglied speichern</button>
|
||||||
|
<p class="muted">Wenn eine Login-E-Mail gesetzt ist, wird automatisch ein Member-Login fuer die digitale Selbstbuchung angelegt.</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="table-card">
|
<section class="table-card">
|
||||||
@@ -33,17 +44,32 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>E-Mail</th>
|
<th>Login</th>
|
||||||
<th>PIN</th>
|
<th>PIN-Status</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($members as $member): ?>
|
<?php foreach ($members as $member): ?>
|
||||||
|
<?php $loginEmail = (string) ($member['login_email'] ?: $member['email']); ?>
|
||||||
|
<?php $hasPin = trim((string) ($member['access_pin'] ?? '')) !== ''; ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= e($member['display_name']) ?></td>
|
<td><?= e($member['display_name']) ?></td>
|
||||||
<td><?= e((string) ($member['login_email'] ?: $member['email'])) ?></td>
|
<td>
|
||||||
<td><?= e((string) ($member['access_pin'] ?? '')) ?></td>
|
<?php if ($loginEmail !== ''): ?>
|
||||||
|
<strong>aktiv</strong><br>
|
||||||
|
<small><?= e($loginEmail) ?></small>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="muted">nur Listenprofil</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if ($hasPin): ?>
|
||||||
|
<code>****</code> hinterlegt
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="muted">nicht gesetzt</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td><?= e($member['status']) ?></td>
|
<td><?= e($member['status']) ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<span class="eyebrow">Klassische Liste</span>
|
<span class="eyebrow">Backoffice</span>
|
||||||
<h1>Papierlisten und Nacherfassung</h1>
|
<h1>Papierlisten und Nacherfassung</h1>
|
||||||
<p>Die klassische Kaffeeliste bleibt erhalten, wird aber als geordneter Beleg mit sauberem Posting ins Ledger behandelt.</p>
|
<p>Die klassische Kaffeeliste bleibt erhalten, wird aber als geordneter Beleg mit sauberem Posting ins Ledger behandelt. Dieser Bereich ist bewusst fuer das Standortteam formuliert, nicht fuer Member-Self-Service.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<span class="eyebrow">Export & Korrekturen</span>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Druck- oder CSV-Export fuer Papierlisten: Platzhalter fuer spaetere Links</li>
|
||||||
|
<li>Rueckfragen zu einzelnen Positionen: Platzhalter fuer kuenftige Korrekturhinweise am Entwurf</li>
|
||||||
|
<li>Member-Self-Service bleibt von Entwurf, Posting und Nachbearbeitung getrennt</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="grid-three">
|
<div class="grid-three">
|
||||||
@@ -105,6 +114,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<?php if ($sheets === []): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">Noch keine Papierlisten angelegt.</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
<?php foreach ($sheets as $sheet): ?>
|
<?php foreach ($sheets as $sheet): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= e($sheet['title']) ?></td>
|
<td><?= e($sheet['title']) ?></td>
|
||||||
@@ -127,6 +141,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<?php if ($sheetLines === []): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">Noch keine Positionen erfasst.</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
<?php foreach ($sheetLines as $line): ?>
|
<?php foreach ($sheetLines as $line): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td>#<?= e((string) $line['paper_sheet_id']) ?></td>
|
<td>#<?= e((string) $line['paper_sheet_id']) ?></td>
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<span class="eyebrow">Finanzen</span>
|
<span class="eyebrow">Backoffice</span>
|
||||||
<h1>Einzahlungen verbuchen</h1>
|
<h1>Einzahlungen verbuchen</h1>
|
||||||
<p>Einzahlungen laufen ins selbe Ledger und reduzieren den offenen Saldo des Mitglieds nachvollziehbar.</p>
|
<p>Diese Seite bleibt bewusst beim Standortteam. Einzahlungen laufen ins selbe Ledger und reduzieren offene Salden nachvollziehbar, waehrend Mitglieder nur die Selbstbuchung sehen.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<span class="eyebrow">Export & Korrekturen</span>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Kassenexport oder Monatsabschluss: Platzhalter fuer kuenftige Download-Links</li>
|
||||||
|
<li>Rueckfragen zu Bareinzahlungen: Platzhalter fuer spaetere Korrekturhinweise direkt am Vorgang</li>
|
||||||
|
<li>Member-Self-Service blendet diesen Backoffice-Bereich bewusst aus</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="two-column">
|
<div class="two-column">
|
||||||
@@ -43,6 +52,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<?php if ($payments === []): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">Noch keine Einzahlungen verbucht.</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
<?php foreach ($payments as $payment): ?>
|
<?php foreach ($payments as $payment): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= e($payment['occurred_at']) ?></td>
|
<td><?= e($payment['occurred_at']) ?></td>
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
<?php require __DIR__ . '/../partials/layout-top.php'; ?>
|
||||||
|
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<span class="eyebrow">Integration</span>
|
<span class="eyebrow">Pilot & Integration</span>
|
||||||
<h1>RFID-Roadmap und Event-Inbox</h1>
|
<h1>RFID-Roadmap und Event-Inbox</h1>
|
||||||
<p>Das MVP speichert Geraete, Karten und rohe Events bereits produktionsnah, ohne die spaetere Hardwareentscheidung vorwegzunehmen.</p>
|
<p>Das MVP speichert Geraete, Karten und rohe Events bereits produktionsnah, ohne die spaetere Hardwareentscheidung vorwegzunehmen. Dieser Bereich bleibt klar beim Standortteam und der Pilotbetreuung.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<span class="eyebrow">Export & Korrekturen</span>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Event-Export oder Inbox-Download: Platzhalter fuer spaetere Support-Links</li>
|
||||||
|
<li>Karten- und Geraetekorrekturen: Platzhalter fuer Hinweise direkt an den Tabellen</li>
|
||||||
|
<li>Member-Self-Service blendet RFID-Setup bewusst aus, bis der Pilot weiter ist</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="grid-three">
|
<div class="grid-three">
|
||||||
@@ -68,6 +77,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<?php if ($devices === []): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">Noch keine RFID-Geraete vorbereitet.</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
<?php foreach ($devices as $device): ?>
|
<?php foreach ($devices as $device): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= e($device['name']) ?></td>
|
<td><?= e($device['name']) ?></td>
|
||||||
@@ -90,6 +104,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<?php if ($tags === []): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">Noch keine RFID-Karten zugewiesen.</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
<?php foreach ($tags as $tag): ?>
|
<?php foreach ($tags as $tag): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= e((string) ($tag['label'] ?: 'ohne Label')) ?></td>
|
<td><?= e((string) ($tag['label'] ?: 'ohne Label')) ?></td>
|
||||||
@@ -114,6 +133,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<?php if ($events === []): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">Noch keine RFID-Events empfangen.</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
<?php foreach ($events as $event): ?>
|
<?php foreach ($events as $event): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= e($event['received_at']) ?></td>
|
<td><?= e($event['received_at']) ?></td>
|
||||||
|
|||||||
Reference in New Issue
Block a user