feat(auth): add invitation direct email delivery, email verification, and password reset flow

This commit is contained in:
Philipp Urbschat 2026-09-12 23:37:04 +02:00
parent 59580d42f9
commit 0ec9143ec8
Signed by: Phili
SSH key fingerprint: SHA256:ZSQWnldzrYiABzOV6vTICPe0h19pTpus7sCbm2S0po0
18 changed files with 903 additions and 20 deletions

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ usage_stats.json
**/usage_stats.json
*.sql
*.sqlite
home/data/
# Dependencies
node_modules/

View file

@ -12,3 +12,11 @@ DB_PASS=your_db_password
# APIs & Externe Dienste
GEMINI_API_KEY=your_gemini_api_key_here
# E-Mail & Versand (Optional: Netcup-Hosting nutzt standardmäßig PHP mail() mit Absender hi@philippurbschat.de)
MAIL_FROM=hi@philippurbschat.de
MAIL_FROM_NAME="Philipp Urbschat"
# SMTP_HOST=mxf9da.netcup.net
# SMTP_PORT=587
# SMTP_USER=hi@philippurbschat.de
# SMTP_PASS=your_mail_password

View file

@ -141,10 +141,23 @@ class AdminController extends Controller {
try {
Security::checkCsrf($_POST['csrf_token'] ?? '');
$invitationModel = $this->model('Invitation');
$userModel = $this->model('User');
$assignedProjects = isset($_POST['projects']) && is_array($_POST['projects']) ? $_POST['projects'] : [];
$note = trim($_POST['note'] ?? '');
$maxUses = !empty($_POST['max_uses']) ? max(1, (int)$_POST['max_uses']) : 1;
$recipientEmail = trim(strtolower($_POST['recipient_email'] ?? ''));
$sendEmailNow = !empty($_POST['send_email_now']);
if (!empty($recipientEmail)) {
if (!filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Bitte gib eine gültige E-Mail-Adresse an.');
}
$existingUser = $userModel->findByEmail($recipientEmail);
if ($existingUser) {
throw new Exception("Ein Benutzer mit der E-Mail '{$recipientEmail}' existiert bereits.");
}
}
$expiresAt = null;
if (!empty($_POST['days_valid']) && (int)$_POST['days_valid'] > 0) {
@ -152,11 +165,18 @@ class AdminController extends Controller {
$expiresAt = (new DateTime("+{$days} days"))->format('Y-m-d H:i:s');
}
$token = $invitationModel->create($assignedProjects, $note ?: null, $maxUses, $expiresAt);
$token = $invitationModel->create($assignedProjects, $note ?: null, $maxUses, $expiresAt, $recipientEmail ?: null);
$baseUrl = rtrim(Config::get('BASE_URL', 'https://philippurbschat.de/'), '/');
$inviteUrl = $baseUrl . '/register?token=' . $token;
Flash::set('Einladungslink erfolgreich erstellt: ' . $inviteUrl, 'success');
$mailSentNotice = '';
if (!empty($recipientEmail) && $sendEmailNow) {
require_once __DIR__ . '/../services/MailService.php';
MailService::sendInvitation($recipientEmail, $token, $assignedProjects, $note ?: null);
$mailSentNotice = " und E-Mail an {$recipientEmail} gesendet";
}
Flash::set('Einladungslink erfolgreich erstellt' . $mailSentNotice . ': ' . $inviteUrl, 'success');
} catch (Exception $e) {
Flash::set('Fehler beim Erstellen des Einladungslinks: ' . $e->getMessage(), 'error');
}
@ -165,6 +185,33 @@ class AdminController extends Controller {
exit;
}
// Resend Invitation Email
public function invite_resend($id) {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
Security::checkCsrf($_POST['csrf_token'] ?? '');
$invitationModel = $this->model('Invitation');
$inv = $invitationModel->getById((int)$id);
if (!$inv) {
throw new Exception('Einladung nicht gefunden.');
}
if (empty($inv['recipient_email'])) {
throw new Exception('Dieser Einladungslink ist keiner konkreten E-Mail-Adresse zugewiesen.');
}
require_once __DIR__ . '/../services/MailService.php';
MailService::sendInvitation($inv['recipient_email'], $inv['token'], $inv['assigned_projects'], $inv['note'] ?? null);
Flash::set("Einladungs-E-Mail erfolgreich erneut an {$inv['recipient_email']} gesendet.", 'success');
} catch (Exception $e) {
Flash::set('Fehler beim Senden der E-Mail: ' . $e->getMessage(), 'error');
}
}
header('Location: ' . Config::get('BASE_URL', '/') . 'admin/users?tab=invites');
exit;
}
// Delete Invitation Link
public function invite_delete($id) {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

View file

@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../../core/Controller.php';
class ForgotPasswordController extends Controller {
public function index() {
if (isset($_SESSION['user_email'])) {
header('Location: ' . Config::get('BASE_URL', '/'));
exit;
}
$this->view('forgot_password', [
'title' => 'Passwort vergessen',
'submitted' => false
]);
}
public function submit() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . Config::get('BASE_URL', '/') . 'forgot-password');
exit;
}
try {
Security::checkCsrf($_POST['csrf_token'] ?? '');
// Simple rate limiting in session
$now = time();
$recentAttempts = $_SESSION['pwd_reset_attempts'] ?? [];
$recentAttempts = array_filter($recentAttempts, function($ts) use ($now) {
return ($now - $ts) < 600; // 10 minutes window
});
if (count($recentAttempts) >= 5) {
throw new Exception('Zu viele Anfragen in kurzer Zeit. Bitte warte einige Minuten, bevor du es erneut versuchst.');
}
$recentAttempts[] = $now;
$_SESSION['pwd_reset_attempts'] = $recentAttempts;
$email = trim(strtolower($_POST['email'] ?? ''));
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$userModel = $this->model('User');
$user = $userModel->findByEmail($email);
if ($user && strtolower($user['status']) === 'active') {
require_once __DIR__ . '/../models/PasswordReset.php';
require_once __DIR__ . '/../services/MailService.php';
$pwdResetModel = new PasswordReset();
$token = $pwdResetModel->createToken($email, 1); // 1 hour validity
MailService::sendPasswordReset($email, $token);
}
}
$this->view('forgot_password', [
'title' => 'Anfrage gesendet',
'submitted' => true,
'email' => $email
]);
} catch (Exception $e) {
Flash::set($e->getMessage(), 'error');
$this->view('forgot_password', [
'title' => 'Passwort vergessen',
'submitted' => false
]);
}
}
}

View file

@ -40,6 +40,7 @@ class RegisterController extends Controller {
'valid' => true,
'token' => $token,
'note' => $invitation['note'] ?? null,
'recipientEmail' => $invitation['recipient_email'] ?? null,
'assignedModules' => $assignedModules
]);
}
@ -71,6 +72,13 @@ class RegisterController extends Controller {
throw new Exception('Der Einladungslink ist ungültig oder abgelaufen.');
}
// Enforce verified recipient email if invitation is personalized
if (!empty($invitation['recipient_email'])) {
if (strtolower($email) !== strtolower($invitation['recipient_email'])) {
throw new Exception('Dieser Einladungslink ist personengebunden und gilt ausschließlich für ' . $invitation['recipient_email']);
}
}
if (empty($name)) {
throw new Exception('Bitte gib deinen Namen ein.');
}

View file

@ -0,0 +1,100 @@
<?php
require_once __DIR__ . '/../../core/Controller.php';
class ResetPasswordController extends Controller {
public function index($urlToken = null) {
if (isset($_SESSION['user_email'])) {
header('Location: ' . Config::get('BASE_URL', '/'));
exit;
}
$token = trim($_GET['token'] ?? $_GET['TOKEN'] ?? $urlToken ?? '');
if (empty($token)) {
$this->view('reset_password', [
'title' => 'Link ungültig',
'valid' => false,
'errorMessage' => 'Kein Reset-Token übergeben.'
]);
return;
}
require_once __DIR__ . '/../models/PasswordReset.php';
$pwdResetModel = new PasswordReset();
$record = $pwdResetModel->getByToken($token);
if (!$record) {
$this->view('reset_password', [
'title' => 'Link abgelaufen oder ungültig',
'valid' => false,
'errorMessage' => 'Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen.'
]);
return;
}
$this->view('reset_password', [
'title' => 'Neues Passwort vergeben',
'valid' => true,
'token' => $token,
'email' => $record['email']
]);
}
public function submit() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . Config::get('BASE_URL', '/'));
exit;
}
$token = trim($_POST['token'] ?? $_POST['TOKEN'] ?? '');
$password = $_POST['password'] ?? '';
$passwordConfirm = $_POST['password_confirm'] ?? '';
$redirectUrl = Config::get('BASE_URL', '/') . 'reset-password?token=' . urlencode($token);
try {
Security::checkCsrf($_POST['csrf_token'] ?? '');
if (empty($token)) {
throw new Exception('Token fehlt.');
}
require_once __DIR__ . '/../models/PasswordReset.php';
$pwdResetModel = new PasswordReset();
$record = $pwdResetModel->getByToken($token);
if (!$record) {
throw new Exception('Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.');
}
if (strlen($password) < 8) {
throw new Exception('Das neue Passwort muss mindestens 8 Zeichen lang sein.');
}
if ($password !== $passwordConfirm) {
throw new Exception('Die eingegebenen Passwörter stimmen nicht überein.');
}
$userModel = $this->model('User');
$user = $userModel->findByEmail($record['email']);
if (!$user) {
throw new Exception('Der zugehörige Benutzeraccount wurde nicht gefunden.');
}
$newHash = password_hash($password, PASSWORD_DEFAULT);
$userModel->updatePassword((int)$user['id'], $newHash);
// Invalidate token
$pwdResetModel->deleteByEmail($record['email']);
Flash::set('Dein Passwort wurde erfolgreich aktualisiert! Du kannst dich jetzt einloggen.', 'success');
header('Location: ' . Config::get('BASE_URL', '/'));
exit;
} catch (Exception $e) {
Flash::set($e->getMessage(), 'error');
header('Location: ' . $redirectUrl);
exit;
}
}
}

View file

@ -6,14 +6,16 @@ class Invitation {
$this->db = new Database();
}
public function create(array $assignedProjects = [], ?string $note = null, int $maxUses = 1, ?string $expiresAt = null): string {
public function create(array $assignedProjects = [], ?string $note = null, int $maxUses = 1, ?string $expiresAt = null, ?string $recipientEmail = null): string {
$token = bin2hex(random_bytes(24)); // 48-char secure hex token
$projectsJson = json_encode(array_values(array_filter($assignedProjects)));
$cleanEmail = !empty($recipientEmail) ? strtolower(trim($recipientEmail)) : null;
$sql = "INSERT INTO home_invitations (token, assigned_projects, max_uses, uses_count, expires_at, note)
VALUES (:token, :assigned_projects, :max_uses, 0, :expires_at, :note)";
$sql = "INSERT INTO home_invitations (token, recipient_email, assigned_projects, max_uses, uses_count, expires_at, note)
VALUES (:token, :recipient_email, :assigned_projects, :max_uses, 0, :expires_at, :note)";
$this->db->query($sql);
$this->db->bind(':token', $token);
$this->db->bind(':recipient_email', $cleanEmail);
$this->db->bind(':assigned_projects', $projectsJson);
$this->db->bind(':max_uses', $maxUses);
$this->db->bind(':expires_at', $expiresAt);
@ -23,6 +25,17 @@ class Invitation {
return $token;
}
public function getById(int $id): ?array {
$sql = "SELECT * FROM home_invitations WHERE id = :id";
$this->db->query($sql);
$this->db->bind(':id', $id);
$inv = $this->db->single();
if ($inv) {
$inv['assigned_projects'] = json_decode($inv['assigned_projects'] ?? '[]', true) ?? [];
}
return $inv ?: null;
}
public function getByToken(string $token): ?array {
$sql = "SELECT * FROM home_invitations WHERE LOWER(token) = LOWER(:token)";
$this->db->query($sql);

View file

@ -0,0 +1,60 @@
<?php
class PasswordReset {
private $db;
public function __construct() {
$this->db = new Database();
}
public function createToken(string $email, int $hoursValid = 1): string {
$cleanEmail = strtolower(trim($email));
$this->deleteByEmail($cleanEmail);
$token = bin2hex(random_bytes(32)); // 64 hex chars
$expiresAt = date('Y-m-d H:i:s', time() + ($hoursValid * 3600));
$sql = "INSERT INTO home_password_resets (email, token, expires_at) VALUES (:email, :token, :expires_at)";
$this->db->query($sql);
$this->db->bind(':email', $cleanEmail);
$this->db->bind(':token', $token);
$this->db->bind(':expires_at', $expiresAt);
$this->db->execute();
return $token;
}
public function getByToken(string $token): ?array {
$cleanToken = trim($token);
$sql = "SELECT * FROM home_password_resets WHERE LOWER(token) = LOWER(:token)";
$this->db->query($sql);
$this->db->bind(':token', $cleanToken);
$record = $this->db->single();
if (!$record) {
return null;
}
// Check expiration
if (new DateTime() > new DateTime($record['expires_at'])) {
$this->deleteToken($cleanToken);
return null;
}
return $record;
}
public function deleteByEmail(string $email): bool {
$cleanEmail = strtolower(trim($email));
$sql = "DELETE FROM home_password_resets WHERE LOWER(email) = LOWER(:email)";
$this->db->query($sql);
$this->db->bind(':email', $cleanEmail);
return $this->db->execute();
}
public function deleteToken(string $token): bool {
$sql = "DELETE FROM home_password_resets WHERE LOWER(token) = LOWER(:token)";
$this->db->query($sql);
$this->db->bind(':token', trim($token));
return $this->db->execute();
}
}

View file

@ -74,4 +74,21 @@ class User {
$this->db->bind(':id', $id);
return $this->db->execute();
}
public function findByEmail(string $email): ?array {
$this->db->query("SELECT * FROM home_users WHERE LOWER(email) = LOWER(:email)");
$this->db->bind(':email', trim($email));
$user = $this->db->single();
if ($user) {
$user['projects'] = json_decode($user['projects'], true) ?? [];
}
return $user ?: null;
}
public function updatePassword(int $id, string $newPasswordHash): bool {
$this->db->query("UPDATE home_users SET password = :password WHERE id = :id");
$this->db->bind(':password', $newPasswordHash);
$this->db->bind(':id', $id);
return $this->db->execute();
}
}

View file

@ -0,0 +1,293 @@
<?php
class MailService {
private static function getBaseUrl(): string {
$base = Config::get('BASE_URL', 'https://philippurbschat.de/');
return rtrim($base, '/') . '/';
}
private static function getFromEmail(): string {
return Config::get('MAIL_FROM', 'hi@philippurbschat.de');
}
private static function getFromName(): string {
return Config::get('MAIL_FROM_NAME', 'Philipp Urbschat');
}
/**
* Send an invitation email with registration link
*/
public static function sendInvitation(string $toEmail, string $token, array $assignedProjects = [], ?string $note = null): bool {
$baseUrl = self::getBaseUrl();
$registerUrl = $baseUrl . 'register?token=' . urlencode($token);
$moduleNames = [];
foreach ($assignedProjects as $slug) {
$mod = ModuleService::getBySlug($slug);
if ($mod) {
$moduleNames[] = strtoupper($mod['name']);
}
}
$modulesText = !empty($moduleNames) ? implode(', ', $moduleNames) : 'Alle Basis-Module';
$subject = 'Persönliche Einladung zu philippurbschat.de';
$bodyHtml = '
<div style="background-color: #020617; color: #e2e8f0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; padding: 40px 20px; text-align: center;">
<div style="max-width: 540px; margin: 0 auto; background-color: #0b1120; border: 1px solid #1e293b; border-radius: 20px; padding: 35px 30px; text-align: left; box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
<div style="font-size: 20px; font-weight: bold; color: #f8fafc; margin-bottom: 25px; letter-spacing: -0.5px;">
<span style="color: #10b981;">_</span>PhilippUrbschat
</div>
<p style="font-size: 14px; line-height: 1.6; color: #cbd5e1; margin-bottom: 18px;">
Hallo,
</p>
<p style="font-size: 14px; line-height: 1.6; color: #cbd5e1; margin-bottom: 18px;">
du wurdest eingeladen, dir einen persönlichen Zugang auf <strong>philippurbschat.de</strong> anzulegen.
</p>
' . (!empty($note) ? '
<div style="background-color: #020617; border-left: 3px solid #06b6d4; padding: 12px 16px; margin: 20px 0; border-radius: 4px; font-size: 13px; color: #94a3b8;">
' . htmlspecialchars($note) . '
</div>' : '') . '
<div style="background-color: #020617; border: 1px solid #1e293b; border-radius: 12px; padding: 16px; margin: 24px 0;">
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: #64748b; margin-bottom: 6px;">Freigeschaltete Module:</div>
<div style="font-size: 13px; font-weight: bold; color: #10b981;">' . htmlspecialchars($modulesText) . '</div>
</div>
<div style="text-align: center; margin: 32px 0;">
<a href="' . htmlspecialchars($registerUrl) . '" style="display: inline-block; background-color: #10b981; color: #020617; font-weight: bold; text-decoration: none; padding: 14px 28px; border-radius: 12px; font-size: 14px; letter-spacing: 0.5px; text-transform: uppercase;">
Jetzt Account erstellen
</a>
</div>
<p style="font-size: 12px; line-height: 1.6; color: #64748b; margin-top: 30px; word-break: break-all;">
Falls der Button nicht funktioniert, kopiere bitte diesen Link in deinen Browser:<br>
<a href="' . htmlspecialchars($registerUrl) . '" style="color: #06b6d4; text-decoration: underline;">' . htmlspecialchars($registerUrl) . '</a>
</p>
</div>
<div style="max-width: 540px; margin: 20px auto 0; font-size: 11px; color: #475569; text-align: center;">
philippurbschat.de · Leopoldstr. 32 · 32756 Detmold
</div>
</div>';
$bodyText = "Hallo,\n\n"
. "du wurdest eingeladen, dir einen persönlichen Zugang auf philippurbschat.de anzulegen.\n\n"
. (!empty($note) ? "Hinweis: {$note}\n\n" : "")
. "Freigeschaltete Systeme: {$modulesText}\n\n"
. "Öffne folgenden Link, um dein Konto zu erstellen:\n"
. $registerUrl . "\n\n"
. "Viele Grüße,\nPhilipp Urbschat";
return self::send($toEmail, $subject, $bodyHtml, $bodyText);
}
/**
* Send a password reset email
*/
public static function sendPasswordReset(string $toEmail, string $token): bool {
$baseUrl = self::getBaseUrl();
$resetUrl = $baseUrl . 'reset-password?token=' . urlencode($token);
$subject = 'Passwort zurücksetzen philippurbschat.de';
$bodyHtml = '
<div style="background-color: #020617; color: #e2e8f0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; padding: 40px 20px; text-align: center;">
<div style="max-width: 540px; margin: 0 auto; background-color: #0b1120; border: 1px solid #1e293b; border-radius: 20px; padding: 35px 30px; text-align: left; box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
<div style="font-size: 20px; font-weight: bold; color: #f8fafc; margin-bottom: 25px; letter-spacing: -0.5px;">
<span style="color: #06b6d4;">_</span>Passwort-Reset
</div>
<p style="font-size: 14px; line-height: 1.6; color: #cbd5e1; margin-bottom: 18px;">
Hallo,
</p>
<p style="font-size: 14px; line-height: 1.6; color: #cbd5e1; margin-bottom: 18px;">
wir haben eine Anfrage zum Zurücksetzen deines Passworts auf <strong>philippurbschat.de</strong> erhalten.
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="' . htmlspecialchars($resetUrl) . '" style="display: inline-block; background-color: #06b6d4; color: #020617; font-weight: bold; text-decoration: none; padding: 14px 28px; border-radius: 12px; font-size: 14px; letter-spacing: 0.5px; text-transform: uppercase;">
Neues Passwort vergeben
</a>
</div>
<p style="font-size: 12px; line-height: 1.6; color: #94a3b8; margin-bottom: 18px;">
Dieser Link ist aus Sicherheitsgründen <strong>1 Stunde</strong> lang gültig.
</p>
<p style="font-size: 12px; line-height: 1.6; color: #64748b; margin-top: 25px; word-break: break-all;">
Falls der Button nicht funktioniert, nutze bitte diesen Link:<br>
<a href="' . htmlspecialchars($resetUrl) . '" style="color: #06b6d4; text-decoration: underline;">' . htmlspecialchars($resetUrl) . '</a>
</p>
<p style="font-size: 11px; line-height: 1.6; color: #475569; margin-top: 25px; border-top: 1px solid #1e293b; padding-top: 15px;">
Wenn du diesen Reset nicht angefordert hast, kannst du diese Nachricht einfach ignorieren. Dein bisheriges Passwort bleibt unverändert gültig.
</p>
</div>
<div style="max-width: 540px; margin: 20px auto 0; font-size: 11px; color: #475569; text-align: center;">
philippurbschat.de · Leopoldstr. 32 · 32756 Detmold
</div>
</div>';
$bodyText = "Hallo,\n\n"
. "wir haben eine Anfrage zum Zurücksetzen deines Passworts auf philippurbschat.de erhalten.\n\n"
. "Klicke auf den folgenden Link, um ein neues Passwort festzulegen (1 Stunde gültig):\n"
. $resetUrl . "\n\n"
. "Falls du diesen Reset nicht angefordert hast, kannst du diese Nachricht ignorieren.\n\n"
. "Viele Grüße,\nPhilipp Urbschat";
return self::send($toEmail, $subject, $bodyHtml, $bodyText);
}
/**
* Internal mail dispatcher
*/
private static function send(string $to, string $subject, string $htmlBody, string $textBody): bool {
$fromEmail = self::getFromEmail();
$fromName = self::getFromName();
// 1. Always log locally for auditing & development
self::logEmail($to, $subject, $textBody);
// 2. Try SMTP if credentials exist
$smtpHost = Config::get('SMTP_HOST');
if (!empty($smtpHost)) {
try {
return self::sendViaSmtp($to, $subject, $htmlBody, $textBody);
} catch (Exception $e) {
error_log("MailService SMTP Error: " . $e->getMessage());
}
}
// 3. Fallback to PHP native mail()
$boundary = "==Multipart_Boundary_x" . md5(uniqid(time()));
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$encodedFromName = '=?UTF-8?B?' . base64_encode($fromName) . '?=';
$headers = [];
$headers[] = "MIME-Version: 1.0";
$headers[] = "From: {$encodedFromName} <{$fromEmail}>";
$headers[] = "Reply-To: {$fromEmail}";
$headers[] = "Content-Type: multipart/alternative; boundary=\"{$boundary}\"";
$headers[] = "X-Mailer: PhilippUrbschat-Mailer/1.0";
$message = "--{$boundary}\r\n";
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
$message .= "Content-Transfer-Encoding: base64\r\n\r\n";
$message .= chunk_split(base64_encode($textBody)) . "\r\n";
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
$message .= "Content-Transfer-Encoding: base64\r\n\r\n";
$message .= chunk_split(base64_encode($htmlBody)) . "\r\n";
$message .= "--{$boundary}--\r\n";
$headerStr = implode("\r\n", $headers);
$additionalParams = "-f " . escapeshellarg($fromEmail);
$sent = @mail($to, $encodedSubject, $message, $headerStr, $additionalParams);
if (!$sent) {
error_log("MailService: mail() returned false. Email was logged to data/mail_log.txt");
}
return true;
}
private static function logEmail(string $to, string $subject, string $body): void {
$logDir = __DIR__ . '/../../data';
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$logFile = $logDir . '/mail_log.txt';
$timestamp = date('Y-m-d H:i:s');
$logEntry = str_repeat('=', 60) . "\n"
. "TIMESTAMP: {$timestamp}\n"
. "TO: {$to}\n"
. "SUBJECT: {$subject}\n"
. "BODY:\n{$body}\n\n";
@file_put_contents($logFile, $logEntry, FILE_APPEND);
}
private static function sendViaSmtp(string $to, string $subject, string $htmlBody, string $textBody): bool {
$host = Config::get('SMTP_HOST');
$port = (int)Config::get('SMTP_PORT', 587);
$user = Config::get('SMTP_USER');
$pass = Config::get('SMTP_PASS');
$from = self::getFromEmail();
$fromName = self::getFromName();
$socket = @fsockopen(($port === 465 ? 'ssl://' : '') . $host, $port, $errno, $errstr, 10);
if (!$socket) {
throw new Exception("Socket connection failed: {$errstr} ({$errno})");
}
$getResponse = function() use ($socket) {
$response = '';
while ($str = fgets($socket, 515)) {
$response .= $str;
if (substr($str, 3, 1) === ' ') break;
}
return $response;
};
$sendCommand = function($cmd) use ($socket, $getResponse) {
fputs($socket, $cmd . "\r\n");
return $getResponse();
};
$getResponse(); // Read greeting
$sendCommand("EHLO " . gethostname());
if ($port === 587) {
$tlsRes = $sendCommand("STARTTLS");
if (strpos($tlsRes, '220') === 0) {
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
$sendCommand("EHLO " . gethostname());
}
}
if (!empty($user) && !empty($pass)) {
$sendCommand("AUTH LOGIN");
$sendCommand(base64_encode($user));
$sendCommand(base64_encode($pass));
}
$sendCommand("MAIL FROM: <{$from}>");
$sendCommand("RCPT TO: <{$to}>");
$sendCommand("DATA");
$boundary = "==Multipart_Boundary_x" . md5(uniqid(time()));
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$encodedFromName = '=?UTF-8?B?' . base64_encode($fromName) . '?=';
$headers = [
"MIME-Version: 1.0",
"From: {$encodedFromName} <{$from}>",
"To: {$to}",
"Subject: {$encodedSubject}",
"Content-Type: multipart/alternative; boundary=\"{$boundary}\"",
"X-Mailer: PhilippUrbschat-Mailer/1.0"
];
$content = implode("\r\n", $headers) . "\r\n\r\n";
$content .= "--{$boundary}\r\n";
$content .= "Content-Type: text/plain; charset=UTF-8\r\n";
$content .= "Content-Transfer-Encoding: base64\r\n\r\n";
$content .= chunk_split(base64_encode($textBody)) . "\r\n";
$content .= "--{$boundary}\r\n";
$content .= "Content-Type: text/html; charset=UTF-8\r\n";
$content .= "Content-Transfer-Encoding: base64\r\n\r\n";
$content .= chunk_split(base64_encode($htmlBody)) . "\r\n";
$content .= "--{$boundary}--\r\n.";
$sendCommand($content);
$sendCommand("QUIT");
fclose($socket);
return true;
}
}

View file

@ -19,6 +19,7 @@ class MigrationService {
$db->query("CREATE TABLE IF NOT EXISTS home_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token VARCHAR(64) NOT NULL UNIQUE,
recipient_email VARCHAR(191) NULL,
assigned_projects TEXT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
uses_count INTEGER NOT NULL DEFAULT 0,
@ -27,6 +28,30 @@ class MigrationService {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
$db->execute();
$db->query("CREATE TABLE IF NOT EXISTS home_password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(191) NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
$db->execute();
// SQLite column check for recipient_email
$db->query("PRAGMA table_info(home_invitations)");
$columns = $db->resultSet();
$hasRecipientEmail = false;
foreach ($columns as $col) {
if (($col['name'] ?? '') === 'recipient_email') {
$hasRecipientEmail = true;
break;
}
}
if (!$hasRecipientEmail) {
$db->query("ALTER TABLE home_invitations ADD COLUMN recipient_email VARCHAR(191) NULL");
$db->execute();
}
} else {
$db->query("CREATE TABLE IF NOT EXISTS home_users (
id INT AUTO_INCREMENT PRIMARY KEY,
@ -43,6 +68,7 @@ class MigrationService {
$db->query("CREATE TABLE IF NOT EXISTS home_invitations (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE,
recipient_email VARCHAR(191) NULL,
assigned_projects TEXT NULL,
max_uses INT NOT NULL DEFAULT 1,
uses_count INT NOT NULL DEFAULT 0,
@ -51,6 +77,23 @@ class MigrationService {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
$db->execute();
$db->query("CREATE TABLE IF NOT EXISTS home_password_resets (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(191) NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
$db->execute();
// MySQL column check for recipient_email
$db->query("SHOW COLUMNS FROM home_invitations LIKE 'recipient_email'");
$colExists = $db->single();
if (!$colExists) {
$db->query("ALTER TABLE home_invitations ADD COLUMN recipient_email VARCHAR(191) NULL AFTER token");
$db->execute();
}
}
// Ensure owner email addresses have admin privileges

View file

@ -7,9 +7,10 @@ class Router {
public function route() {
$url = $this->parseUrl();
$controllerPath = __DIR__ . '/../app/controllers/';
// Controller Check (Case-insensitive)
// Controller Check (Case-insensitive & kebab-case support)
if (isset($url[0])) {
$candidate = ucfirst(strtolower($url[0])) . 'Controller';
$normalizedName = str_replace('-', '', ucwords(strtolower($url[0]), '-'));
$candidate = $normalizedName . 'Controller';
if (file_exists($controllerPath . $candidate . '.php')) {
$this->controller = $candidate;
unset($url[0]);
@ -17,11 +18,11 @@ class Router {
}
require_once $controllerPath . $this->controller . '.php';
$this->controller = new $this->controller;
// Method Check (Case-insensitive)
// Method Check (Case-insensitive & kebab-case support)
if (isset($url[1])) {
$candidateMethod = $url[1];
$candidateMethod = str_replace('-', '', strtolower($url[1]));
foreach (get_class_methods($this->controller) as $methodName) {
if (strcasecmp($methodName, $candidateMethod) === 0 && strpos($methodName, '_') !== 0) {
if (strcasecmp(str_replace('_', '', $methodName), $candidateMethod) === 0 && strpos($methodName, '_') !== 0) {
$this->method = $methodName;
unset($url[1]);
break;

File diff suppressed because one or more lines are too long

View file

@ -55,9 +55,13 @@ $currentTab = $activeTab ?? ($_GET['tab'] ?? 'users');
<form action="<?= $baseUrl ?>/admin/invite_create" method="POST" class="space-y-4">
<input type="hidden" name="csrf_token" value="<?= Security::csrf() ?>">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label class="block text-slate-400 font-mono text-[11px] uppercase tracking-wider mb-1.5">Notiz / Empfänger</label>
<label class="block text-slate-400 font-mono text-[11px] uppercase tracking-wider mb-1.5">Empfänger E-Mail (optional)</label>
<input type="email" name="recipient_email" placeholder="name@beispiel.de" class="w-full bg-slate-900 border border-slate-800 rounded-xl p-3 text-slate-200 text-xs font-mono focus:outline-none focus:border-cyan-500 transition-colors">
</div>
<div>
<label class="block text-slate-400 font-mono text-[11px] uppercase tracking-wider mb-1.5">Notiz / Name</label>
<input type="text" name="note" placeholder="z.B. Für Alex" class="w-full bg-slate-900 border border-slate-800 rounded-xl p-3 text-slate-200 text-xs font-mono focus:outline-none focus:border-cyan-500 transition-colors">
</div>
<div>
@ -65,11 +69,18 @@ $currentTab = $activeTab ?? ($_GET['tab'] ?? 'users');
<input type="number" name="max_uses" value="1" min="1" max="100" class="w-full bg-slate-900 border border-slate-800 rounded-xl p-3 text-slate-200 text-xs font-mono focus:outline-none focus:border-cyan-500 transition-colors">
</div>
<div>
<label class="block text-slate-400 font-mono text-[11px] uppercase tracking-wider mb-1.5">Gültigkeit in Tagen (optional)</label>
<label class="block text-slate-400 font-mono text-[11px] uppercase tracking-wider mb-1.5">Gültigkeit in Tagen</label>
<input type="number" name="days_valid" placeholder="z.B. 7 (leer = unbegrenzt)" min="1" max="365" class="w-full bg-slate-900 border border-slate-800 rounded-xl p-3 text-slate-200 text-xs font-mono focus:outline-none focus:border-cyan-500 transition-colors">
</div>
</div>
<div class="pt-1">
<label class="flex items-center gap-2 text-xs font-mono text-slate-300 cursor-pointer select-none">
<input type="checkbox" name="send_email_now" value="1" checked class="accent-cyan-500">
<span>Einladungs-E-Mail direkt versenden (wenn Empfänger-E-Mail angegeben)</span>
</label>
</div>
<div>
<label class="block text-slate-400 font-mono text-[11px] uppercase tracking-wider mb-2">Automatisch freizuschaltende Module</label>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2.5">
@ -96,17 +107,18 @@ $currentTab = $activeTab ?? ($_GET['tab'] ?? 'users');
<thead class="bg-slate-950 text-cyan-400 uppercase text-[10px] tracking-wider">
<tr>
<th class="px-4 py-3 border-b border-slate-800">Token & Link</th>
<th class="px-4 py-3 border-b border-slate-800">Empfänger</th>
<th class="px-4 py-3 border-b border-slate-800">Notiz</th>
<th class="px-4 py-3 border-b border-slate-800">Module</th>
<th class="px-4 py-3 border-b border-slate-800">Nutzungen</th>
<th class="px-4 py-3 border-b border-slate-800">Gültig bis</th>
<th class="px-4 py-3 border-b border-slate-800">Status</th>
<th class="px-4 py-3 border-b border-slate-800 text-right">Aktion</th>
<th class="px-4 py-3 border-b border-slate-800 text-right">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60 bg-slate-900/30">
<?php if (empty($invitations)): ?>
<tr><td colspan="7" class="px-4 py-6 text-center text-slate-500">Noch keine Einladungslinks erstellt.</td></tr>
<tr><td colspan="8" class="px-4 py-6 text-center text-slate-500">Noch keine Einladungslinks erstellt.</td></tr>
<?php else: foreach ($invitations as $inv):
$fullUrl = $baseUrl . '/register?token=' . $inv['token'];
?>
@ -119,6 +131,13 @@ $currentTab = $activeTab ?? ($_GET['tab'] ?? 'users');
</button>
</div>
</td>
<td class="px-4 py-3.5">
<?php if (!empty($inv['recipient_email'])): ?>
<span class="text-cyan-400 font-medium"><?= htmlspecialchars($inv['recipient_email']) ?></span>
<?php else: ?>
<span class="text-slate-600"></span>
<?php endif; ?>
</td>
<td class="px-4 py-3.5 text-slate-200"><?= htmlspecialchars($inv['note'] ?: '-') ?></td>
<td class="px-4 py-3.5">
<div class="flex flex-wrap gap-1">
@ -142,7 +161,15 @@ $currentTab = $activeTab ?? ($_GET['tab'] ?? 'users');
<span class="px-2 py-0.5 bg-rose-500/10 text-rose-400 border border-rose-500/30 rounded-md text-[10px] uppercase">Inaktiv</span>
<?php endif; ?>
</td>
<td class="px-4 py-3.5 text-right">
<td class="px-4 py-3.5 text-right whitespace-nowrap">
<?php if (!empty($inv['recipient_email']) && $inv['is_valid']): ?>
<form action="<?= $baseUrl ?>/admin/invite_resend/<?= $inv['id'] ?>" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="<?= Security::csrf() ?>">
<button type="submit" class="px-2 py-1 bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 rounded-lg text-[10px] uppercase font-mono mr-2 cursor-pointer transition-colors" title="Einladung erneut per E-Mail senden">
E-Mail senden
</button>
</form>
<?php endif; ?>
<form action="<?= $baseUrl ?>/admin/invite_delete/<?= $inv['id'] ?>" method="POST" class="inline" onsubmit="return confirm('Diesen Einladungslink wirklich löschen?');">
<input type="hidden" name="csrf_token" value="<?= Security::csrf() ?>">
<button type="submit" class="text-rose-400 hover:text-rose-300 text-xs cursor-pointer">Löschen</button>

View file

@ -0,0 +1,88 @@
<?php
$baseUrl = rtrim(Config::get('BASE_URL', 'https://philippurbschat.de/'), '/');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#020617">
<title><?= htmlspecialchars($title ?? 'Passwort vergessen') ?> | Philipp Urbschat</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
</head>
<body class="bg-[#0b1120] text-slate-300 min-h-screen flex flex-col justify-center items-center p-4 sm:p-6 font-sans relative overflow-x-hidden selection-pink">
<div class="max-w-md w-full my-8 relative z-10">
<!-- Logo / Brand Header -->
<div class="text-center mb-8">
<a href="<?= $baseUrl ?>" class="inline-flex items-center gap-2 text-slate-200 hover:text-cyan-400 font-mono text-xs uppercase tracking-widest transition-colors">
<span class="w-2 h-2 rounded-full bg-cyan-400 animate-pulse"></span> philippurbschat.de
</a>
</div>
<?php Flash::display(); ?>
<?php if (!empty($submitted)): ?>
<!-- Success Confirmation Card -->
<div class="bg-slate-900/90 border border-cyan-500/30 rounded-3xl p-6 sm:p-8 backdrop-blur-xl shadow-[0_0_40px_rgba(6,182,212,0.12)] relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-cyan-500 to-transparent"></div>
<div class="w-12 h-12 rounded-full bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center text-cyan-400 font-mono text-lg mb-4">
</div>
<h1 class="text-2xl font-black text-slate-100 font-mono tracking-tight mb-2">E-Mail versendet</h1>
<p class="text-slate-300 text-sm font-mono leading-relaxed mb-6">
Falls ein Account mit der Adresse <strong class="text-cyan-400"><?= htmlspecialchars($email ?? '') ?></strong> existiert, haben wir einen Link zum Zurücksetzen deines Passworts verschickt.
</p>
<div class="p-4 bg-slate-950/70 border border-slate-800 rounded-xl mb-6 text-xs text-slate-400 font-mono space-y-2">
<p> Der Link ist aus Sicherheitsgründen für <strong>1 Stunde</strong> gültig.</p>
<p> Bitte prüfe auch deinen <strong>Spam-Ordner</strong>, falls die Nachricht nicht zeitnah ankommt.</p>
</div>
<a href="<?= $baseUrl ?>" class="block w-full text-center bg-cyan-500/10 border border-cyan-500/30 hover:bg-cyan-500/20 text-cyan-400 font-bold font-mono uppercase tracking-wider py-3.5 rounded-xl transition-all text-xs">
Zurück zur Startseite
</a>
</div>
<?php else: ?>
<!-- Request Reset Form Card -->
<div class="bg-slate-900/90 border border-cyan-500/30 rounded-3xl p-6 sm:p-8 backdrop-blur-xl shadow-[0_0_40px_rgba(6,182,212,0.12)] relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-cyan-500 to-transparent"></div>
<div class="mb-6">
<span class="inline-block px-3 py-1 bg-cyan-500/10 border border-cyan-500/30 rounded-full text-cyan-400 font-mono text-[11px] uppercase tracking-wider mb-3">
Security / Recovery
</span>
<h1 class="text-2xl font-black text-slate-100 font-mono tracking-tight">Passwort vergessen?</h1>
<p class="text-slate-400 text-xs mt-1 font-mono">Gib deine E-Mail-Adresse ein. Wir senden dir einen Wiederherstellungslink.</p>
</div>
<form action="<?= $baseUrl ?>/forgot-password/submit" method="POST" class="space-y-4">
<input type="hidden" name="csrf_token" value="<?= Security::csrf() ?>">
<div>
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider mb-1.5">E-Mail-Adresse</label>
<input type="email" name="email" required placeholder="name@beispiel.de" autocomplete="email" autofocus class="w-full bg-slate-950/80 border border-slate-700/80 rounded-xl p-3.5 text-slate-200 text-sm focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all placeholder:text-slate-600 font-mono">
</div>
<button type="submit" class="w-full cursor-pointer bg-cyan-500/20 border border-cyan-500/50 hover:bg-cyan-500/30 text-cyan-400 hover:text-cyan-300 font-bold font-mono uppercase tracking-wider py-4 rounded-xl transition-all duration-300 transform active:scale-95 shadow-[0_0_20px_rgba(6,182,212,0.15)] mt-4">
Reset-Link anfordern
</button>
</form>
<div class="mt-6 pt-5 border-t border-slate-800 text-center">
<a href="<?= $baseUrl ?>" class="text-xs font-mono text-slate-500 hover:text-slate-300 transition-colors">
Zurück zum Login
</a>
</div>
</div>
<?php endif; ?>
</div>
</body>
</html>

View file

@ -90,7 +90,12 @@
<form action="<?= Config::get('BASE_URL', '/') ?>login" method="POST" class="space-y-4 relative z-10">
<input type="hidden" name="csrf_token" value="<?= Security::csrf() ?>">
<input type="email" name="email" placeholder="Email" autocomplete="username" required class="w-full bg-slate-950/80 border border-slate-700/80 rounded-2xl p-4 text-slate-200 text-sm focus:outline-none focus:border-fuchsia-500/50 focus:ring-1 focus:ring-fuchsia-500/50 transition-all placeholder:text-slate-500">
<input type="password" name="password" placeholder="Password" autocomplete="current-password" required class="w-full bg-slate-950/80 border border-slate-700/80 rounded-2xl p-4 text-slate-200 text-sm focus:outline-none focus:border-fuchsia-500/50 focus:ring-1 focus:ring-fuchsia-500/50 transition-all placeholder:text-slate-500">
<div class="space-y-1.5">
<input type="password" name="password" placeholder="Password" autocomplete="current-password" required class="w-full bg-slate-950/80 border border-slate-700/80 rounded-2xl p-4 text-slate-200 text-sm focus:outline-none focus:border-fuchsia-500/50 focus:ring-1 focus:ring-fuchsia-500/50 transition-all placeholder:text-slate-500">
<div class="text-right px-1">
<a href="<?= Config::get('BASE_URL', '/') ?>forgot-password" class="text-[11px] font-mono text-slate-500 hover:text-fuchsia-400 transition-colors">Passwort vergessen?</a>
</div>
</div>
<button type="submit" class="w-full cursor-pointer bg-slate-900/50 border border-fuchsia-500/40 hover:bg-fuchsia-500/10 text-fuchsia-400 hover:text-fuchsia-300 font-bold font-mono uppercase tracking-wider py-4 rounded-2xl transition-all duration-300 transform active:scale-95 shadow-[0_0_10px_rgba(217,70,239,0.05)] hover:shadow-[0_0_15px_rgba(217,70,239,0.2)]">Authenticate</button>
</form>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-fuchsia-500/5 rounded-full blur-3xl"></div>

View file

@ -68,8 +68,13 @@ $baseUrl = rtrim(Config::get('BASE_URL', 'https://philippurbschat.de/'), '/');
</div>
<div>
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider mb-1.5">E-Mail-Adresse</label>
<input type="email" name="email" required placeholder="name@beispiel.de" autocomplete="email" class="w-full bg-slate-950/80 border border-slate-700/80 rounded-xl p-3.5 text-slate-200 text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-600 font-mono">
<div class="flex items-center justify-between mb-1.5">
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider">E-Mail-Adresse</label>
<?php if (!empty($recipientEmail)): ?>
<span class="text-[10px] text-cyan-400 font-mono border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 rounded">Einladung gebunden</span>
<?php endif; ?>
</div>
<input type="email" name="email" value="<?= htmlspecialchars($recipientEmail ?? '') ?>" <?= !empty($recipientEmail) ? 'readonly' : '' ?> required placeholder="name@beispiel.de" autocomplete="email" class="w-full bg-slate-950/80 border <?= !empty($recipientEmail) ? 'border-cyan-500/40 text-cyan-200 cursor-not-allowed' : 'border-slate-700/80 text-slate-200' ?> rounded-xl p-3.5 text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-600 font-mono">
</div>
<div>

View file

@ -0,0 +1,97 @@
<?php
$baseUrl = rtrim(Config::get('BASE_URL', 'https://philippurbschat.de/'), '/');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#020617">
<title><?= htmlspecialchars($title ?? 'Passwort zurücksetzen') ?> | Philipp Urbschat</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
</head>
<body class="bg-[#0b1120] text-slate-300 min-h-screen flex flex-col justify-center items-center p-4 sm:p-6 font-sans relative overflow-x-hidden selection-pink">
<div class="max-w-md w-full my-8 relative z-10">
<!-- Logo / Brand Header -->
<div class="text-center mb-8">
<a href="<?= $baseUrl ?>" class="inline-flex items-center gap-2 text-slate-200 hover:text-cyan-400 font-mono text-xs uppercase tracking-widest transition-colors">
<span class="w-2 h-2 rounded-full bg-cyan-400 animate-pulse"></span> philippurbschat.de
</a>
</div>
<?php Flash::display(); ?>
<?php if (!empty($valid)): ?>
<!-- Reset Password Form Card -->
<div class="bg-slate-900/90 border border-cyan-500/30 rounded-3xl p-6 sm:p-8 backdrop-blur-xl shadow-[0_0_40px_rgba(6,182,212,0.12)] relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-cyan-500 to-transparent"></div>
<div class="mb-6">
<span class="inline-block px-3 py-1 bg-cyan-500/10 border border-cyan-500/30 rounded-full text-cyan-400 font-mono text-[11px] uppercase tracking-wider mb-3">
Token bestätigt
</span>
<h1 class="text-2xl font-black text-slate-100 font-mono tracking-tight">Neues Passwort vergeben</h1>
<p class="text-slate-400 text-xs mt-1 font-mono">
Account: <strong class="text-slate-200"><?= htmlspecialchars($email ?? '') ?></strong>
</p>
</div>
<form action="<?= $baseUrl ?>/reset-password/submit" method="POST" class="space-y-4">
<input type="hidden" name="csrf_token" value="<?= Security::csrf() ?>">
<input type="hidden" name="token" value="<?= htmlspecialchars($token ?? '') ?>">
<div>
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider mb-1.5">Neues Passwort</label>
<input type="password" name="password" required minlength="8" placeholder="Mindestens 8 Zeichen" autocomplete="new-password" autofocus class="w-full bg-slate-950/80 border border-slate-700/80 rounded-xl p-3.5 text-slate-200 text-sm focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all placeholder:text-slate-600 font-mono">
</div>
<div>
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider mb-1.5">Passwort wiederholen</label>
<input type="password" name="password_confirm" required minlength="8" placeholder="Passwort wiederholen" autocomplete="new-password" class="w-full bg-slate-950/80 border border-slate-700/80 rounded-xl p-3.5 text-slate-200 text-sm focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all placeholder:text-slate-600 font-mono">
</div>
<button type="submit" class="w-full cursor-pointer bg-cyan-500/20 border border-cyan-500/50 hover:bg-cyan-500/30 text-cyan-400 hover:text-cyan-300 font-bold font-mono uppercase tracking-wider py-4 rounded-xl transition-all duration-300 transform active:scale-95 shadow-[0_0_20px_rgba(6,182,212,0.15)] mt-6">
Passwort speichern
</button>
</form>
<div class="mt-6 pt-5 border-t border-slate-800 text-center">
<a href="<?= $baseUrl ?>" class="text-xs font-mono text-slate-500 hover:text-slate-300 transition-colors">
Zurück zum Login
</a>
</div>
</div>
<?php else: ?>
<!-- Invalid Token Card -->
<div class="bg-slate-900/90 border border-rose-500/30 rounded-3xl p-6 sm:p-8 backdrop-blur-xl shadow-[0_0_40px_rgba(244,63,94,0.12)] relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-rose-500 to-transparent"></div>
<div class="w-12 h-12 rounded-full bg-rose-500/10 border border-rose-500/30 flex items-center justify-center text-rose-400 font-mono text-lg mb-4">
</div>
<h1 class="text-2xl font-black text-slate-100 font-mono tracking-tight mb-2">Ungültiger Link</h1>
<p class="text-slate-300 text-sm font-mono leading-relaxed mb-6">
<?= htmlspecialchars($errorMessage ?? 'Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen.') ?>
</p>
<a href="<?= $baseUrl ?>/forgot-password" class="block w-full text-center bg-rose-500/10 border border-rose-500/30 hover:bg-rose-500/20 text-rose-400 font-bold font-mono uppercase tracking-wider py-3.5 rounded-xl transition-all text-xs mb-3">
Neuen Reset-Link anfordern
</a>
<div class="text-center pt-2">
<a href="<?= $baseUrl ?>" class="text-xs font-mono text-slate-500 hover:text-slate-300 transition-colors">
Zur Startseite
</a>
</div>
</div>
<?php endif; ?>
</div>
</body>
</html>