feat(auth): add invitation direct email delivery, email verification, and password reset flow
This commit is contained in:
parent
59580d42f9
commit
0ec9143ec8
18 changed files with 903 additions and 20 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,6 +15,7 @@ usage_stats.json
|
|||
**/usage_stats.json
|
||||
*.sql
|
||||
*.sqlite
|
||||
home/data/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
70
home/app/controllers/ForgotPasswordController.php
Normal file
70
home/app/controllers/ForgotPasswordController.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
100
home/app/controllers/ResetPasswordController.php
Normal file
100
home/app/controllers/ResetPasswordController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
60
home/app/models/PasswordReset.php
Normal file
60
home/app/models/PasswordReset.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
293
home/app/services/MailService.php
Normal file
293
home/app/services/MailService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
88
home/views/forgot_password.php
Normal file
88
home/views/forgot_password.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
97
home/views/reset_password.php
Normal file
97
home/views/reset_password.php
Normal 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>
|
||||
Loading…
Reference in a new issue