Merge pull request 'Add password reset functionality and SMTP mailer class' (#31) from dev into main
Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
@@ -136,6 +136,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$message = "AI Co-Pilot instellingen zijn succesvol opgeslagen.";
|
$message = "AI Co-Pilot instellingen zijn succesvol opgeslagen.";
|
||||||
$messageType = 'success';
|
$messageType = 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Test e-mail versturen via SMTP (Release v1.6)
|
||||||
|
elseif ($action === 'send_test_email') {
|
||||||
|
require_once __DIR__ . '/includes/mailer.php';
|
||||||
|
$testEmail = trim($_POST['test_email'] ?? '');
|
||||||
|
if (empty($testEmail) || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$message = "Vul een geldig e-mailadres in om een test e-mail te sturen.";
|
||||||
|
$messageType = 'error';
|
||||||
|
} else {
|
||||||
|
$mailResult = FocizMailer::sendTestEmail($testEmail, $currentUser['name']);
|
||||||
|
if ($mailResult['success']) {
|
||||||
|
$message = "Test e-mail succesvol verzonden via mail.fociz.nl naar " . htmlspecialchars($testEmail) . "!";
|
||||||
|
$messageType = 'success';
|
||||||
|
} else {
|
||||||
|
$message = "Fout bij verzenden test e-mail via mail.fociz.nl: " . htmlspecialchars($mailResult['error'] ?? 'Onbekende fout');
|
||||||
|
$messageType = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Haal actuele configuratie op
|
// Haal actuele configuratie op
|
||||||
@@ -646,6 +665,73 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. SMTP Mail Server & Notificaties (Release v1.6) -->
|
||||||
|
<div class="fociz-card p-6 sm:p-7 space-y-6">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
|
||||||
|
<div class="flex items-center space-x-3.5">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs">
|
||||||
|
<i data-lucide="mail" class="w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">SMTP Mail Server & Notificaties</h2>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400">Uitgaande e-mailserver voor wachtwoord-vergeten, uitnodigingen en notificaties</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="px-2.5 py-1 rounded-full bg-teal-50 dark:bg-teal-950/40 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60 text-xs font-bold self-start sm:self-auto">
|
||||||
|
mail.fociz.nl:587 • Actief
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuratie Overzicht -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80 flex justify-between items-center">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400">SMTP Host & Poort:</span>
|
||||||
|
<span class="font-mono font-bold text-slate-900 dark:text-white"><?= htmlspecialchars(defined('SMTP_HOST') ? SMTP_HOST : 'mail.fociz.nl') ?>:<?= htmlspecialchars((string)(defined('SMTP_PORT') ? SMTP_PORT : 587)) ?> (STARTTLS)</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80 flex justify-between items-center">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400">SMTP Gebruiker:</span>
|
||||||
|
<span class="font-mono font-bold text-slate-900 dark:text-white"><?= htmlspecialchars(defined('SMTP_USER') ? SMTP_USER : 'info@fociz.nl') ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80 flex justify-between items-center">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400">Zichtbare Afzender (From):</span>
|
||||||
|
<span class="font-semibold text-slate-900 dark:text-white"><?= htmlspecialchars(defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Fociz') ?> <<?= htmlspecialchars(defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl') ?>></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80 flex justify-between items-center">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400">Antwoordadres (Reply-To):</span>
|
||||||
|
<span class="font-semibold text-slate-900 dark:text-white"><?= htmlspecialchars(defined('SMTP_REPLY_TO') ? SMTP_REPLY_TO : 'info@fociz.nl') ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test E-mail Versturen Formulier -->
|
||||||
|
<div class="pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||||
|
<h3 class="text-xs font-bold text-slate-800 dark:text-slate-200 mb-2 flex items-center space-x-1.5">
|
||||||
|
<i data-lucide="send" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||||||
|
<span>Directe SMTP Verbindingstest</span>
|
||||||
|
</h3>
|
||||||
|
<p class="text-[11px] text-slate-500 dark:text-slate-400 mb-3">
|
||||||
|
Verstuur een realtime testmail met het Fociz HTML-sjabloon om de verbinding, STARTTLS-handshake en aflevering direct te verifiëren.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST" action="/admin.php" class="flex flex-col sm:flex-row gap-2.5">
|
||||||
|
<input type="hidden" name="action" value="send_test_email">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="test_email"
|
||||||
|
required
|
||||||
|
value="<?= htmlspecialchars($currentUser['email'] ?? '') ?>"
|
||||||
|
placeholder="test.ontvanger@bedrijf.nl"
|
||||||
|
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-slate-900 dark:text-white text-xs placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer flex-shrink-0">
|
||||||
|
<i data-lucide="send" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Testmail Verzenden</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rechterkolom: Systeem & Team Monitor (4 cols) -->
|
<!-- Rechterkolom: Systeem & Team Monitor (4 cols) -->
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Fociz Wachtwoord Vergeten Aanvraag
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/mailer.php';
|
||||||
|
|
||||||
|
start_secure_session();
|
||||||
|
|
||||||
|
// Indien al ingelogd, direct doorsturen naar de planner
|
||||||
|
if (get_logged_in_user()) {
|
||||||
|
header('Location: /planner.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
$submitted = false;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||||
|
|
||||||
|
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$error = "Vul een geldig e-mailadres in.";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$db = get_db_connection();
|
||||||
|
|
||||||
|
// Zoek gebruiker op
|
||||||
|
$stmt = $db->prepare("SELECT id, name, email FROM users WHERE email = ?");
|
||||||
|
$stmt->execute([$email]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
// Verwijder eventuele eerdere openstaande reset tokens voor dit e-mailadres
|
||||||
|
$stmtClean = $db->prepare("DELETE FROM password_resets WHERE email = ? AND used_at IS NULL");
|
||||||
|
$stmtClean->execute([$email]);
|
||||||
|
|
||||||
|
// Genereer veilig uniek token
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour'));
|
||||||
|
|
||||||
|
$stmtInsert = $db->prepare("
|
||||||
|
INSERT INTO password_resets (email, token, expires_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmtInsert->execute([$email, $token, $expiresAt]);
|
||||||
|
|
||||||
|
// Bouw herstel-URL
|
||||||
|
$baseUrl = rtrim(APP_URL, '/');
|
||||||
|
$resetUrl = "{$baseUrl}/auth/reset-password.php?token={$token}";
|
||||||
|
|
||||||
|
// Verstuur herstelmail via de nieuwe FocizMailer
|
||||||
|
$mailResult = FocizMailer::sendPasswordResetEmail($user['email'], $user['name'], $resetUrl);
|
||||||
|
if (!$mailResult['success']) {
|
||||||
|
error_log("Fout bij verzenden wachtwoordherstel mail naar {$email}: " . ($mailResult['error'] ?? 'Onbekend'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anti-enumeratie: Toon altijd succes ongeacht of het e-mailadres bestaat
|
||||||
|
$submitted = true;
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("Wachtwoord vergeten fout: " . $e->getMessage());
|
||||||
|
$error = "Er is een technische fout opgetreden. Probeer het over enkele ogenblikken opnieuw.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageTitle = "Fociz — Wachtwoord Vergeten";
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="nl" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
|
||||||
|
var isDark = true;
|
||||||
|
if (activeTheme === 'dark') isDark = true;
|
||||||
|
else if (activeTheme === 'light') isDark = false;
|
||||||
|
else if (activeTheme === 'system') {
|
||||||
|
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
if (isDark) document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
slate: {
|
||||||
|
850: '#151f32',
|
||||||
|
950: '#070b12',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
|
||||||
|
|
||||||
|
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
|
||||||
|
|
||||||
|
<div class="w-full max-w-md bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
|
||||||
|
<i data-lucide="key-round" class="w-7 h-7"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-black tracking-tight text-slate-900 dark:text-white">Wachtwoord Vergeten?</h1>
|
||||||
|
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1">
|
||||||
|
Geen probleem. Vul uw e-mailadres in en we sturen u een veilige herstellink.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="mb-6 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
|
||||||
|
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
|
||||||
|
<span><?= htmlspecialchars($error) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($submitted): ?>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="p-5 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-800 dark:text-emerald-300 text-sm">
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<i data-lucide="mail-check" class="w-6 h-6 flex-shrink-0 text-emerald-600 dark:text-emerald-400 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<strong class="font-bold block mb-1">Herstellink verzonden!</strong>
|
||||||
|
<span>
|
||||||
|
Als het opgegeven e-mailadres bij ons bekend is, ontvangt u binnen enkele ogenblikken een e-mail met instructies om uw wachtwoord opnieuw in te stellen.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-slate-500 dark:text-slate-400 space-y-2 bg-slate-50 dark:bg-slate-900/50 p-4 rounded-xl border border-slate-200 dark:border-slate-800">
|
||||||
|
<p>💡 <strong>Tip:</strong> Heeft u na enkele minuten nog niets ontvangen?</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1 pl-1">
|
||||||
|
<li>Controleer uw map met <em>Ongewenste e-mail</em> of <em>Spam</em>.</li>
|
||||||
|
<li>Controleer of u het juiste e-mailadres heeft ingevuld.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/auth/login.php" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||||
|
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||||
|
<span>Terug naar Inloggen</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="POST" action="/auth/forgot-password.php" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Uw e-mailadres</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
placeholder="uw.naam@bedrijf.nl"
|
||||||
|
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>"
|
||||||
|
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||||
|
<i data-lucide="send" class="w-4 h-4"></i>
|
||||||
|
<span>Herstellink Verzenden</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="/auth/login.php" class="inline-flex items-center space-x-1.5 text-xs text-indigo-600 dark:text-indigo-400 hover:underline font-medium">
|
||||||
|
<i data-lucide="arrow-left" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Terug naar Inloggen</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center text-xs text-slate-500">
|
||||||
|
Beveiligd met end-to-end authenticatie • Fociz 2026
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
+4
-1
@@ -215,7 +215,10 @@ $pageTitle = "Fociz — Inloggen";
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord</label>
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Wachtwoord</label>
|
||||||
|
<a href="/auth/forgot-password.php" class="text-xs text-indigo-600 dark:text-indigo-400 hover:underline font-medium">Wachtwoord vergeten?</a>
|
||||||
|
</div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Fociz Wachtwoord Opnieuw Instellen
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
start_secure_session();
|
||||||
|
|
||||||
|
// Indien al ingelogd, direct doorsturen naar de planner
|
||||||
|
if (get_logged_in_user()) {
|
||||||
|
header('Location: /planner.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = trim($_GET['token'] ?? ($_POST['token'] ?? ''));
|
||||||
|
$db = get_db_connection();
|
||||||
|
|
||||||
|
// Valideer token
|
||||||
|
$resetRecord = null;
|
||||||
|
if (!empty($token)) {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT pr.*, u.name, u.email as user_email
|
||||||
|
FROM password_resets pr
|
||||||
|
JOIN users u ON pr.email = u.email
|
||||||
|
WHERE pr.token = ? AND pr.used_at IS NULL AND pr.expires_at > NOW()
|
||||||
|
");
|
||||||
|
$stmt->execute([$token]);
|
||||||
|
$resetRecord = $stmt->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
$success = false;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $resetRecord) {
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
$confirmPassword = $_POST['confirm_password'] ?? '';
|
||||||
|
|
||||||
|
if (empty($password)) {
|
||||||
|
$error = "Vul een nieuw wachtwoord in.";
|
||||||
|
} elseif (strlen($password) < 8) {
|
||||||
|
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn.";
|
||||||
|
} elseif ($password !== $confirmPassword) {
|
||||||
|
$error = "De wachtwoorden komen niet overeen.";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
$newHash = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
|
// 1. Update wachtwoord van de gebruiker en reset remember tokens
|
||||||
|
$stmtUser = $db->prepare("
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?, remember_token = NULL, remember_token_expires_at = NULL
|
||||||
|
WHERE email = ?
|
||||||
|
");
|
||||||
|
$stmtUser->execute([$newHash, $resetRecord['user_email']]);
|
||||||
|
|
||||||
|
// 2. Markeer reset token als verbruikt
|
||||||
|
$stmtToken = $db->prepare("UPDATE password_resets SET used_at = NOW() WHERE token = ?");
|
||||||
|
$stmtToken->execute([$token]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
$success = true;
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
error_log("Fout bij resetten wachtwoord: " . $e->getMessage());
|
||||||
|
$error = "Er is een fout opgetreden bij het opslaan van uw nieuwe wachtwoord.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageTitle = "Fociz — Wachtwoord Opnieuw Instellen";
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="nl" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
|
||||||
|
var isDark = true;
|
||||||
|
if (activeTheme === 'dark') isDark = true;
|
||||||
|
else if (activeTheme === 'light') isDark = false;
|
||||||
|
else if (activeTheme === 'system') {
|
||||||
|
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
if (isDark) document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
slate: {
|
||||||
|
850: '#151f32',
|
||||||
|
950: '#070b12',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
|
||||||
|
|
||||||
|
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
|
||||||
|
|
||||||
|
<div class="w-full max-w-md bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
|
||||||
|
|
||||||
|
<?php if (!$resetRecord && !$success): ?>
|
||||||
|
<!-- Ongeldige of Verlopen Link -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-rose-50 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-800/60 text-rose-600 dark:text-rose-400 mb-4 shadow-xs">
|
||||||
|
<i data-lucide="shield-alert" class="w-7 h-7"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-black tracking-tight text-slate-900 dark:text-white mb-2">Herstellink Ongeldig</h1>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mb-6 leading-relaxed">
|
||||||
|
Deze wachtwoordherstellink is ongeldig, verlopen (langer dan 60 minuten geleden aangevraagd) of al eerder gebruikt.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<a href="/auth/forgot-password.php" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||||
|
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||||
|
<span>Nieuwe herstellink aanvragen</span>
|
||||||
|
</a>
|
||||||
|
<a href="/auth/login.php" class="block text-center text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 font-medium py-2">
|
||||||
|
Naar Inloggen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php elseif ($success): ?>
|
||||||
|
<!-- Succesvol Gewijzigd -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/60 text-emerald-600 dark:text-emerald-400 mb-4 shadow-xs">
|
||||||
|
<i data-lucide="check-circle" class="w-7 h-7"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-black tracking-tight text-slate-900 dark:text-white mb-2">Wachtwoord Gewijzigd!</h1>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mb-6 leading-relaxed">
|
||||||
|
Uw nieuwe wachtwoord is succesvol opgeslagen. U kunt nu direct inloggen in Fociz.
|
||||||
|
</p>
|
||||||
|
<a href="/auth/login.php" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||||
|
<span>Inloggen met Nieuw Wachtwoord</span>
|
||||||
|
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
<!-- Formulier: Nieuw Wachtwoord Invoeren -->
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
|
||||||
|
<i data-lucide="lock" class="w-7 h-7"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-black tracking-tight text-slate-900 dark:text-white">Nieuw Wachtwoord</h1>
|
||||||
|
<p class="text-slate-500 dark:text-slate-400 text-xs mt-1">
|
||||||
|
Kies een nieuw veilig wachtwoord voor <strong class="text-slate-800 dark:text-slate-200"><?= htmlspecialchars($resetRecord['user_email']) ?></strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="mb-5 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
|
||||||
|
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
|
||||||
|
<span><?= htmlspecialchars($error) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" action="/auth/reset-password.php" class="space-y-4">
|
||||||
|
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Nieuw wachtwoord</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
id="pwd"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autofocus
|
||||||
|
placeholder="Minimaal 8 tekens"
|
||||||
|
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition"
|
||||||
|
oninput="checkPasswordStrength(this.value)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- Sterkte Indicator -->
|
||||||
|
<div class="mt-2 flex items-center space-x-1.5">
|
||||||
|
<div id="bar1" class="h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors"></div>
|
||||||
|
<div id="bar2" class="h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors"></div>
|
||||||
|
<div id="bar3" class="h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors"></div>
|
||||||
|
<span id="strengthText" class="text-[10px] text-slate-400 pl-1 font-medium"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig nieuw wachtwoord</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
placeholder="Herhaal wachtwoord"
|
||||||
|
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||||
|
<i data-lucide="check" class="w-4 h-4"></i>
|
||||||
|
<span>Wachtwoord Opslaan</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="/auth/login.php" class="text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 font-medium">
|
||||||
|
Annuleren en terug naar inloggen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center text-xs text-slate-500">
|
||||||
|
Beveiligd met end-to-end authenticatie • Fociz 2026
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
|
||||||
|
function checkPasswordStrength(val) {
|
||||||
|
var b1 = document.getElementById('bar1');
|
||||||
|
var b2 = document.getElementById('bar2');
|
||||||
|
var b3 = document.getElementById('bar3');
|
||||||
|
var txt = document.getElementById('strengthText');
|
||||||
|
if (!b1 || !b2 || !b3 || !txt) return;
|
||||||
|
|
||||||
|
b1.className = 'h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors';
|
||||||
|
b2.className = 'h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors';
|
||||||
|
b3.className = 'h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors';
|
||||||
|
txt.textContent = '';
|
||||||
|
|
||||||
|
if (!val) return;
|
||||||
|
|
||||||
|
var score = 0;
|
||||||
|
if (val.length >= 8) score++;
|
||||||
|
if (/[A-Z]/.test(val) && /[0-9]/.test(val)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(val) && val.length >= 10) score++;
|
||||||
|
|
||||||
|
if (score === 1) {
|
||||||
|
b1.className = 'h-1 flex-1 rounded-full bg-amber-500';
|
||||||
|
txt.textContent = 'Matig';
|
||||||
|
txt.className = 'text-[10px] text-amber-500 pl-1 font-medium';
|
||||||
|
} else if (score === 2) {
|
||||||
|
b1.className = 'h-1 flex-1 rounded-full bg-indigo-500';
|
||||||
|
b2.className = 'h-1 flex-1 rounded-full bg-indigo-500';
|
||||||
|
txt.textContent = 'Goed';
|
||||||
|
txt.className = 'text-[10px] text-indigo-400 pl-1 font-medium';
|
||||||
|
} else if (score >= 3) {
|
||||||
|
b1.className = 'h-1 flex-1 rounded-full bg-emerald-500';
|
||||||
|
b2.className = 'h-1 flex-1 rounded-full bg-emerald-500';
|
||||||
|
b3.className = 'h-1 flex-1 rounded-full bg-emerald-500';
|
||||||
|
txt.textContent = 'Sterk';
|
||||||
|
txt.className = 'text-[10px] text-emerald-400 pl-1 font-medium';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -25,4 +25,14 @@ JIRA_DOMAIN=""
|
|||||||
JIRA_EMAIL=""
|
JIRA_EMAIL=""
|
||||||
JIRA_API_TOKEN=""
|
JIRA_API_TOKEN=""
|
||||||
|
|
||||||
|
# SMTP Mail Server
|
||||||
|
SMTP_HOST="mail.fociz.nl"
|
||||||
|
SMTP_PORT="587"
|
||||||
|
SMTP_USER="info@fociz.nl"
|
||||||
|
SMTP_PASS=""
|
||||||
|
SMTP_SECURE="tls"
|
||||||
|
SMTP_FROM_EMAIL="no-reply@fociz.nl"
|
||||||
|
SMTP_FROM_NAME="Fociz"
|
||||||
|
SMTP_REPLY_TO="info@fociz.nl"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,18 @@ define('APP_URL', fociz_detect_app_url());
|
|||||||
// Tijdzone
|
// Tijdzone
|
||||||
date_default_timezone_set('Europe/Amsterdam');
|
date_default_timezone_set('Europe/Amsterdam');
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3b. SMTP MAIL SERVER CONFIGURATIE
|
||||||
|
// =========================================================================
|
||||||
|
define('SMTP_HOST', fociz_env('SMTP_HOST', 'mail.fociz.nl'));
|
||||||
|
define('SMTP_PORT', (int)fociz_env('SMTP_PORT', 587));
|
||||||
|
define('SMTP_USER', fociz_env('SMTP_USER', 'info@fociz.nl'));
|
||||||
|
define('SMTP_PASS', fociz_env('SMTP_PASS', '6swpQSBg5uWFmjar7F6a'));
|
||||||
|
define('SMTP_SECURE', fociz_env('SMTP_SECURE', 'tls')); // 'tls' (STARTTLS port 587) of 'ssl' (port 465)
|
||||||
|
define('SMTP_FROM_EMAIL', fociz_env('SMTP_FROM_EMAIL', 'no-reply@fociz.nl'));
|
||||||
|
define('SMTP_FROM_NAME', fociz_env('SMTP_FROM_NAME', 'Fociz'));
|
||||||
|
define('SMTP_REPLY_TO', fociz_env('SMTP_REPLY_TO', 'info@fociz.nl'));
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 4. VEILIGE FOUTAFHANDELING & PEN TEST BESCHERMING
|
// 4. VEILIGE FOUTAFHANDELING & PEN TEST BESCHERMING
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ function ensure_tables_exist(PDO $pdo): void {
|
|||||||
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255) NULL AFTER theme",
|
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255) NULL AFTER theme",
|
||||||
"ALTER TABLE users ADD COLUMN backup_codes TEXT NULL AFTER two_factor_enabled",
|
"ALTER TABLE users ADD COLUMN backup_codes TEXT NULL AFTER two_factor_enabled",
|
||||||
"ALTER TABLE board_card_schedules ADD UNIQUE KEY uk_sched_card (card_id)",
|
"ALTER TABLE board_card_schedules ADD UNIQUE KEY uk_sched_card (card_id)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS `password_resets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `email` VARCHAR(191) NOT NULL, `token` VARCHAR(64) NOT NULL UNIQUE, `expires_at` DATETIME NOT NULL, `used_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`email`), INDEX (`token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
];
|
];
|
||||||
foreach ($colUpgrades as $sql) {
|
foreach ($colUpgrades as $sql) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ CREATE TABLE IF NOT EXISTS `invites` (
|
|||||||
CONSTRAINT `fk_invites_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
CONSTRAINT `fk_invites_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `password_resets` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`token` VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`used_at` DATETIME NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX (`email`),
|
||||||
|
INDEX (`token`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `daily_plans` (
|
CREATE TABLE IF NOT EXISTS `daily_plans` (
|
||||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
`user_id` INT NOT NULL,
|
`user_id` INT NOT NULL,
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Fociz Pure-PHP SMTP Mailer & Responsive HTML E-mail Engine
|
||||||
|
*
|
||||||
|
* Volledig zelfstandige, veilige SMTP-client zonder externe afhankelijkheden.
|
||||||
|
* Ondersteunt STARTTLS (poort 587), SSL (poort 465), AUTH LOGIN/PLAIN en
|
||||||
|
* levert responsive, hoogwaardig gestylede HTML e-mails in de Fociz-huisstijl.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
class FocizMailer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verstuurt een e-mail via native PHP stream sockets over SMTP.
|
||||||
|
*
|
||||||
|
* @param string $toEmail Ontvanger e-mailadres
|
||||||
|
* @param string $toName Ontvanger weergavenaam
|
||||||
|
* @param string $subject Onderwerp van het bericht
|
||||||
|
* @param string $htmlBody Opgemaakte HTML inhoud
|
||||||
|
* @param string|null $plainTextBody Optionele platte tekst (wordt anders afgeleid uit HTML)
|
||||||
|
* @param array $options Extra opties (from_email, from_name, reply_to, headers)
|
||||||
|
* @return array ['success' => bool, 'error' => ?string, 'logs' => array]
|
||||||
|
*/
|
||||||
|
public static function send(
|
||||||
|
string $toEmail,
|
||||||
|
string $toName,
|
||||||
|
string $subject,
|
||||||
|
string $htmlBody,
|
||||||
|
?string $plainTextBody = null,
|
||||||
|
array $options = []
|
||||||
|
): array {
|
||||||
|
$logs = [];
|
||||||
|
$log = function(string $msg) use (&$logs) {
|
||||||
|
$logs[] = date('H:i:s') . ' ' . $msg;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Configuratieparameters ophalen
|
||||||
|
$host = $options['host'] ?? (defined('SMTP_HOST') ? SMTP_HOST : 'mail.fociz.nl');
|
||||||
|
$port = (int)($options['port'] ?? (defined('SMTP_PORT') ? SMTP_PORT : 587));
|
||||||
|
$username = $options['username'] ?? (defined('SMTP_USER') ? SMTP_USER : 'info@fociz.nl');
|
||||||
|
$password = $options['password'] ?? (defined('SMTP_PASS') ? SMTP_PASS : '');
|
||||||
|
$secure = strtolower($options['secure'] ?? (defined('SMTP_SECURE') ? SMTP_SECURE : 'tls'));
|
||||||
|
$fromEmail = $options['from_email'] ?? (defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl');
|
||||||
|
$fromName = $options['from_name'] ?? (defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Fociz');
|
||||||
|
$replyTo = $options['reply_to'] ?? (defined('SMTP_REPLY_TO') ? SMTP_REPLY_TO : 'info@fociz.nl');
|
||||||
|
$timeout = (int)($options['timeout'] ?? 15);
|
||||||
|
|
||||||
|
// Technische afzender voor de SMTP envelope (moet doorgaans overeenkomen met het geauthenticeerde account)
|
||||||
|
$envelopeFrom = !empty($username) && filter_var($username, FILTER_VALIDATE_EMAIL) ? $username : $fromEmail;
|
||||||
|
|
||||||
|
if (empty($plainTextBody)) {
|
||||||
|
$plainTextBody = self::htmlToPlainText($htmlBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
$log("Verbinding initialiseren naar {$host}:{$port} (beveiliging: {$secure})...");
|
||||||
|
|
||||||
|
// 2. Socket verbinden
|
||||||
|
$socketContext = stream_context_create([
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => false,
|
||||||
|
'verify_peer_name' => false,
|
||||||
|
'allow_self_signed' => true,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$remoteTarget = ($secure === 'ssl' || $port === 465) ? "ssl://{$host}:{$port}" : "tcp://{$host}:{$port}";
|
||||||
|
$errno = 0;
|
||||||
|
$errstr = '';
|
||||||
|
|
||||||
|
$socket = @stream_socket_client(
|
||||||
|
$remoteTarget,
|
||||||
|
$errno,
|
||||||
|
$errstr,
|
||||||
|
$timeout,
|
||||||
|
STREAM_CLIENT_CONNECT,
|
||||||
|
$socketContext
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$socket) {
|
||||||
|
$err = "Kan geen socketverbinding openen naar {$host}:{$port} [{$errno}]: {$errstr}";
|
||||||
|
$log("FOUT: {$err}");
|
||||||
|
error_log("FocizMailer: " . $err);
|
||||||
|
return ['success' => false, 'error' => $err, 'logs' => $logs];
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_set_timeout($socket, $timeout);
|
||||||
|
|
||||||
|
// Helperfuncties voor SMTP commando's
|
||||||
|
$readResponse = function() use ($socket, $log): array {
|
||||||
|
$data = '';
|
||||||
|
$code = 0;
|
||||||
|
while (!feof($socket)) {
|
||||||
|
$line = fgets($socket, 1024);
|
||||||
|
if ($line === false) break;
|
||||||
|
$data .= $line;
|
||||||
|
if (preg_match('/^([0-9]{3})([ -])(.*)$/', $line, $matches)) {
|
||||||
|
$code = (int)$matches[1];
|
||||||
|
if ($matches[2] === ' ') {
|
||||||
|
// Laatste regel van het antwoord
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$log("<< " . trim(preg_replace('/\r?\n/', ' | ', $data)));
|
||||||
|
return ['code' => $code, 'text' => $data];
|
||||||
|
};
|
||||||
|
|
||||||
|
$sendCommand = function(string $cmd, string $maskLog = '') use ($socket, $log, $readResponse): array {
|
||||||
|
$logMsg = !empty($maskLog) ? $maskLog : $cmd;
|
||||||
|
$log(">> " . trim($logMsg));
|
||||||
|
fwrite($socket, $cmd . "\r\n");
|
||||||
|
return $readResponse();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. Welkomstbanner lezen (220)
|
||||||
|
$resp = $readResponse();
|
||||||
|
if ($resp['code'] !== 220) {
|
||||||
|
throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. EHLO Handshake
|
||||||
|
$clientHost = !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'fociz.nl';
|
||||||
|
$resp = $sendCommand("EHLO {$clientHost}");
|
||||||
|
if ($resp['code'] !== 250) {
|
||||||
|
$resp = $sendCommand("HELO {$clientHost}");
|
||||||
|
if ($resp['code'] !== 250) {
|
||||||
|
throw new Exception("EHLO/HELO geweigerd door server: {$resp['text']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls')
|
||||||
|
if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) {
|
||||||
|
$log("STARTTLS handshake starten...");
|
||||||
|
$resp = $sendCommand("STARTTLS");
|
||||||
|
if ($resp['code'] !== 220) {
|
||||||
|
throw new Exception("STARTTLS geweigerd door server (code {$resp['code']}): {$resp['text']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crypto activeren op de actieve stream
|
||||||
|
$cryptoMethod = STREAM_CRYPTO_METHOD_TLS_CLIENT;
|
||||||
|
if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) {
|
||||||
|
$cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
|
||||||
|
}
|
||||||
|
if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) {
|
||||||
|
$cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cryptoSuccess = @stream_socket_enable_crypto($socket, true, $cryptoMethod);
|
||||||
|
if (!$cryptoSuccess) {
|
||||||
|
throw new Exception("TLS encryptiehandshake kon niet tot stand worden gebracht.");
|
||||||
|
}
|
||||||
|
$log("TLS encryptie succesvol geactiveerd.");
|
||||||
|
|
||||||
|
// Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207)
|
||||||
|
$resp = $sendCommand("EHLO {$clientHost}");
|
||||||
|
if ($resp['code'] !== 250) {
|
||||||
|
throw new Exception("EHLO na TLS handshake geweigerd: {$resp['text']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. SMTP Authenticatie
|
||||||
|
if (!empty($username) && !empty($password)) {
|
||||||
|
$log("Authenticatie uitvoeren als '{$username}'...");
|
||||||
|
$resp = $sendCommand("AUTH LOGIN");
|
||||||
|
if ($resp['code'] === 334) {
|
||||||
|
// Stuur gebruikersnaam
|
||||||
|
$resp = $sendCommand(base64_encode($username), "AUTH USER [base64]");
|
||||||
|
if ($resp['code'] !== 334) {
|
||||||
|
throw new Exception("Gebruikersnaam geweigerd voor authenticatie: {$resp['text']}");
|
||||||
|
}
|
||||||
|
// Stuur wachtwoord
|
||||||
|
$resp = $sendCommand(base64_encode($password), "AUTH PASS [base64 masked]");
|
||||||
|
if ($resp['code'] !== 235) {
|
||||||
|
throw new Exception("Wachtwoord onjuist of authenticatie mislukt (code {$resp['code']}): {$resp['text']}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback naar AUTH PLAIN
|
||||||
|
$plainToken = base64_encode("\0" . $username . "\0" . $password);
|
||||||
|
$resp = $sendCommand("AUTH PLAIN {$plainToken}", "AUTH PLAIN [token masked]");
|
||||||
|
if ($resp['code'] !== 235) {
|
||||||
|
throw new Exception("SMTP authenticatie geweigerd (code {$resp['code']}): {$resp['text']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$log("SMTP Authenticatie geslaagd.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Afzender en Ontvanger instellen
|
||||||
|
$resp = $sendCommand("MAIL FROM:<{$envelopeFrom}>");
|
||||||
|
if ($resp['code'] !== 250) {
|
||||||
|
// Probeer met fromEmail indien envelopeFrom faalt
|
||||||
|
if ($envelopeFrom !== $fromEmail) {
|
||||||
|
$resp = $sendCommand("MAIL FROM:<{$fromEmail}>");
|
||||||
|
}
|
||||||
|
if ($resp['code'] !== 250) {
|
||||||
|
throw new Exception("MAIL FROM geweigerd (code {$resp['code']}): {$resp['text']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$resp = $sendCommand("RCPT TO:<{$toEmail}>");
|
||||||
|
if ($resp['code'] !== 250 && $resp['code'] !== 251) {
|
||||||
|
throw new Exception("RCPT TO geweigerd voor {$toEmail} (code {$resp['code']}): {$resp['text']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. DATA Commando
|
||||||
|
$resp = $sendCommand("DATA");
|
||||||
|
if ($resp['code'] !== 354) {
|
||||||
|
throw new Exception("Server niet gereed voor e-maildata (code {$resp['code']}): {$resp['text']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. MIME Multipart/Alternative Samenstellen
|
||||||
|
$boundary = "----=_Part_Fociz_" . md5(uniqid((string)time(), true));
|
||||||
|
$encodedSubject = "=?UTF-8?B?" . base64_encode($subject) . "?=";
|
||||||
|
$encodedFromName = "=?UTF-8?B?" . base64_encode($fromName) . "?=";
|
||||||
|
$encodedToName = !empty($toName) ? "=?UTF-8?B?" . base64_encode($toName) . "?= " : "";
|
||||||
|
|
||||||
|
$headers = [];
|
||||||
|
$headers[] = "Date: " . date(DATE_RFC2822);
|
||||||
|
$headers[] = "From: {$encodedFromName} <{$fromEmail}>";
|
||||||
|
$headers[] = "To: {$encodedToName}<{$toEmail}>";
|
||||||
|
if (!empty($replyTo)) {
|
||||||
|
$headers[] = "Reply-To: <{$replyTo}>";
|
||||||
|
}
|
||||||
|
$headers[] = "Subject: {$encodedSubject}";
|
||||||
|
$headers[] = "Message-ID: <" . time() . "." . bin2hex(random_bytes(8)) . "@" . ($clientHost ?: 'fociz.nl') . ">";
|
||||||
|
$headers[] = "X-Mailer: Fociz Mail Engine 2026";
|
||||||
|
$headers[] = "MIME-Version: 1.0";
|
||||||
|
$headers[] = "Content-Type: multipart/alternative; boundary=\"{$boundary}\"";
|
||||||
|
|
||||||
|
$rawEmail = implode("\r\n", $headers) . "\r\n\r\n";
|
||||||
|
|
||||||
|
// Deel 1: Platte tekst fallback
|
||||||
|
$rawEmail .= "--{$boundary}\r\n";
|
||||||
|
$rawEmail .= "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n";
|
||||||
|
$rawEmail .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||||
|
$rawEmail .= $plainTextBody . "\r\n\r\n";
|
||||||
|
|
||||||
|
// Deel 2: Luxe HTML variant
|
||||||
|
$rawEmail .= "--{$boundary}\r\n";
|
||||||
|
$rawEmail .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||||
|
$rawEmail .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||||
|
$rawEmail .= $htmlBody . "\r\n\r\n";
|
||||||
|
|
||||||
|
// Sluiting boundary
|
||||||
|
$rawEmail .= "--{$boundary}--\r\n";
|
||||||
|
|
||||||
|
// Zorg voor veilige dot-stuffing (regels die beginnen met een punt krijgen een extra punt)
|
||||||
|
$safeLines = [];
|
||||||
|
foreach (explode("\n", str_replace("\r\n", "\n", $rawEmail)) as $line) {
|
||||||
|
if (isset($line[0]) && $line[0] === '.') {
|
||||||
|
$line = '.' . $line;
|
||||||
|
}
|
||||||
|
$safeLines[] = $line;
|
||||||
|
}
|
||||||
|
$finalPayload = implode("\r\n", $safeLines) . "\r\n.\r\n";
|
||||||
|
|
||||||
|
// Schrijf e-mail inhoud
|
||||||
|
$log("E-maildata overdragen (" . strlen($finalPayload) . " bytes)...");
|
||||||
|
fwrite($socket, $finalPayload);
|
||||||
|
$resp = $readResponse();
|
||||||
|
|
||||||
|
if ($resp['code'] !== 250) {
|
||||||
|
throw new Exception("Fout bij accepteren van e-maildata door de server (code {$resp['code']}): {$resp['text']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$log("E-mail succesvol geaccepteerd voor aflevering door {$host}.");
|
||||||
|
|
||||||
|
// 10. QUIT Commando
|
||||||
|
$sendCommand("QUIT");
|
||||||
|
@fclose($socket);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'error' => null,
|
||||||
|
'logs' => $logs
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$log("FOUT: " . $e->getMessage());
|
||||||
|
error_log("FocizMailer Fout: " . $e->getMessage());
|
||||||
|
@fwrite($socket, "QUIT\r\n");
|
||||||
|
@fclose($socket);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'logs' => $logs
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genereert een hoogwaardig gestylede, responsive HTML e-mail template
|
||||||
|
* in de kenmerkende moderne Fociz donker/licht huisstijl.
|
||||||
|
*/
|
||||||
|
public static function renderEmailTemplate(array $params): string {
|
||||||
|
$preheader = htmlspecialchars($params['preheader'] ?? 'Bericht vanuit Fociz');
|
||||||
|
$badge = htmlspecialchars($params['badge'] ?? 'FOCIZ SYSTEEM');
|
||||||
|
$title = htmlspecialchars($params['title'] ?? 'Belangrijk bericht van Fociz');
|
||||||
|
$contentHtml = $params['content_html'] ?? '';
|
||||||
|
$ctaText = !empty($params['cta_text']) ? htmlspecialchars($params['cta_text']) : null;
|
||||||
|
$ctaUrl = !empty($params['cta_url']) ? htmlspecialchars($params['cta_url']) : null;
|
||||||
|
$ctaSubtext = !empty($params['cta_subtext']) ? htmlspecialchars($params['cta_subtext']) : null;
|
||||||
|
$securityNotice = $params['security_notice'] ?? 'Heeft u dit verzoek niet zelf ingediend? Dan kunt u deze e-mail veilig negeren. Uw gegevens blijven optimaal beschermd.';
|
||||||
|
$currentYear = date('Y');
|
||||||
|
$appUrl = defined('APP_URL') ? APP_URL : 'https://www.fociz.nl';
|
||||||
|
|
||||||
|
$ctaBlock = '';
|
||||||
|
if ($ctaText && $ctaUrl) {
|
||||||
|
$ctaBlock = "
|
||||||
|
<!-- Call To Action Knop -->
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' style='margin: 32px 0 24px 0;'>
|
||||||
|
<tr>
|
||||||
|
<td align='center' style='border-radius: 12px; background: #4f46e5;'>
|
||||||
|
<a href='{$ctaUrl}' target='_blank' style='display: inline-block; padding: 14px 32px; font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 700; color: #ffffff; text-decoration: none; border-radius: 12px; background: #4f46e5; letter-spacing: 0.2px;'>
|
||||||
|
{$ctaText} →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
" . ($ctaSubtext ? "<p style='margin: 0 0 20px 0; font-size: 12px; color: #64748b; line-height: 1.5;'>{$ctaSubtext}</p>" : "") . "
|
||||||
|
<p style='margin: 16px 0 0 0; font-size: 11px; color: #94a3b8; word-break: break-all; line-height: 1.5;'>
|
||||||
|
Werkt de knop niet? Kopieer en plak dan deze link in uw browser:<br>
|
||||||
|
<a href='{$ctaUrl}' style='color: #6366f1; text-decoration: underline;'>{$ctaUrl}</a>
|
||||||
|
</p>
|
||||||
|
";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<!DOCTYPE html>
|
||||||
|
<html lang='nl'>
|
||||||
|
<head>
|
||||||
|
<meta charset='UTF-8'>
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||||
|
<title>{$title}</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type='text/css'>
|
||||||
|
body, table, td, a { font-family: Arial, sans-serif !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; min-width: 100%; background-color: #0c0f17; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
|
||||||
|
img { border: 0; outline: none; text-decoration: none; }
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
td { border-collapse: collapse; }
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-container { width: 100% !important; padding: 12px !important; }
|
||||||
|
.card-content { padding: 24px 20px !important; }
|
||||||
|
.header-banner { padding: 28px 20px !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style='margin: 0; padding: 0; background-color: #0c0f17; -webkit-font-smoothing: antialiased;'>
|
||||||
|
<!-- Verborgen Pre-header tekst voor inbox preview -->
|
||||||
|
<div style='display: none; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: #0c0f17; opacity: 0;'>
|
||||||
|
{$preheader} ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='background-color: #0c0f17;'>
|
||||||
|
<tr>
|
||||||
|
<td align='center' style='padding: 40px 16px 50px 16px;'>
|
||||||
|
<!-- Hoofdcontainer -->
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='max-width: 580px;' class='email-container'>
|
||||||
|
|
||||||
|
<!-- Luxe Bovenkaart -->
|
||||||
|
<tr>
|
||||||
|
<td style='background: #111622; border-top-left-radius: 20px; border-top-right-radius: 20px; border: 1px solid #1e293b; border-bottom: none; padding: 36px 36px 32px 36px;' class='header-banner'>
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<!-- Logo & Titel -->
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0'>
|
||||||
|
<tr>
|
||||||
|
<td style='width: 44px; height: 44px; background: linear-gradient(135deg, #4f46e5 0%, #312e81 100%); border-radius: 12px; text-align: center; vertical-align: middle; border: 1px solid #6366f1; box-shadow: 0 4px 12px rgba(79, 70, 229, 0.35);'>
|
||||||
|
<span style='font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif; font-size: 22px; font-weight: 900; color: #ffffff; line-height: 44px; display: inline-block;'>F</span>
|
||||||
|
</td>
|
||||||
|
<td style='padding-left: 14px;'>
|
||||||
|
<span style='font-size: 22px; font-weight: 900; color: #ffffff; letter-spacing: -0.5px; display: block;'>Fociz</span>
|
||||||
|
<span style='font-size: 11px; font-weight: 500; color: #94a3b8; letter-spacing: 0.5px; text-transform: uppercase;'>Focus op wat telt</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td align='right' style='vertical-align: top;'>
|
||||||
|
<!-- Badge -->
|
||||||
|
<span style='display: inline-block; padding: 5px 12px; font-size: 10px; font-weight: 700; color: #818cf8; background: rgba(99, 102, 241, 0.12); border: 1px solid rgba(99, 102, 241, 0.28); border-radius: 20px; letter-spacing: 0.6px; text-transform: uppercase;'>
|
||||||
|
{$badge}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Hoofdkaart met Inhoud -->
|
||||||
|
<tr>
|
||||||
|
<td style='background: #151d2f; border-bottom-left-radius: 20px; border-bottom-right-radius: 20px; border: 1px solid #1e293b; border-top: 1px solid #243048; padding: 36px;' class='card-content'>
|
||||||
|
|
||||||
|
<h1 style='margin: 0 0 16px 0; font-size: 22px; font-weight: 800; color: #ffffff; letter-spacing: -0.4px; line-height: 1.3;'>
|
||||||
|
{$title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div style='font-size: 14px; line-height: 1.7; color: #cbd5e1;'>
|
||||||
|
{$contentHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{$ctaBlock}
|
||||||
|
|
||||||
|
<!-- Beveiligings- & Waarschuwingsbox -->
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='margin-top: 32px; border-top: 1px solid #1e293b; padding-top: 20px;'>
|
||||||
|
<tr>
|
||||||
|
<td style='background: rgba(15, 23, 42, 0.6); border: 1px solid #243048; border-radius: 12px; padding: 14px 16px;'>
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0'>
|
||||||
|
<tr>
|
||||||
|
<td style='vertical-align: top; padding-right: 10px; font-size: 16px;'>
|
||||||
|
🔒
|
||||||
|
</td>
|
||||||
|
<td style='font-size: 12px; line-height: 1.5; color: #94a3b8;'>
|
||||||
|
{$securityNotice}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td align='center' style='padding: 28px 20px 0 20px;'>
|
||||||
|
<p style='margin: 0 0 6px 0; font-size: 12px; color: #64748b; font-weight: 500;'>
|
||||||
|
© {$currentYear} Fociz • Veilige Project- & Dagplanning
|
||||||
|
</p>
|
||||||
|
<p style='margin: 0; font-size: 11px; color: #475569;'>
|
||||||
|
Dit is een geautomatiseerd systeembericht verzonden naar u door Fociz.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verstuurt een 'Wachtwoord Vergeten' herstelmail
|
||||||
|
*/
|
||||||
|
public static function sendPasswordResetEmail(string $email, string $name, string $resetUrl): array {
|
||||||
|
$cleanName = !empty($name) ? htmlspecialchars($name) : 'Collega';
|
||||||
|
$title = "Herstel uw Fociz wachtwoord";
|
||||||
|
$preheader = "U heeft een verzoek ingediend om uw Fociz wachtwoord opnieuw in te stellen.";
|
||||||
|
|
||||||
|
$contentHtml = "
|
||||||
|
<p style='margin: 0 0 14px 0;'>Beste <strong>{$cleanName}</strong>,</p>
|
||||||
|
<p style='margin: 0 0 14px 0;'>
|
||||||
|
Er is zojuist een verzoek gedaan om het wachtwoord van uw Fociz-account opnieuw in te stellen voor het e-mailadres <strong style='color: #e2e8f0;'>{$email}</strong>.
|
||||||
|
</p>
|
||||||
|
<p style='margin: 0 0 14px 0;'>
|
||||||
|
Klik op onderstaande knop om direct een nieuw wachtwoord te kiezen. Deze link is uit veiligheidsoverwegingen <strong>60 minuten geldig</strong>.
|
||||||
|
</p>
|
||||||
|
";
|
||||||
|
|
||||||
|
$html = self::renderEmailTemplate([
|
||||||
|
'title' => $title,
|
||||||
|
'preheader' => $preheader,
|
||||||
|
'badge' => 'VEILIGHEID',
|
||||||
|
'content_html' => $contentHtml,
|
||||||
|
'cta_text' => 'Wachtwoord Opnieuw Instellen',
|
||||||
|
'cta_url' => $resetUrl,
|
||||||
|
'cta_subtext' => '⏱️ Deze herstellink verloopt over 60 minuten en kan slechts éénmaal gebruikt worden.',
|
||||||
|
'security_notice' => 'Heeft u dit verzoek niet zelf gedaan? Geen zorgen, uw wachtwoord blijft ongewijzigd en uw account is veilig. U kunt deze e-mail gerust negeren.'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::send($email, $name, "Herstel uw Fociz wachtwoord", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verstuurt een team-uitnodiging naar een nieuwe collega
|
||||||
|
*/
|
||||||
|
public static function sendInvitationEmail(
|
||||||
|
string $email,
|
||||||
|
string $inviterName,
|
||||||
|
string $role,
|
||||||
|
string $inviteUrl
|
||||||
|
): array {
|
||||||
|
$cleanInviter = !empty($inviterName) ? htmlspecialchars($inviterName) : 'Een beheerder';
|
||||||
|
$roleLabel = ($role === 'ADMIN') ? 'Beheerder (Admin)' : 'Teamlid (Member)';
|
||||||
|
$title = "U bent uitgenodigd voor Fociz";
|
||||||
|
$preheader = "{$cleanInviter} heeft u uitgenodigd om samen te werken in Fociz.";
|
||||||
|
|
||||||
|
$contentHtml = "
|
||||||
|
<p style='margin: 0 0 14px 0;'>Hallo,</p>
|
||||||
|
<p style='margin: 0 0 14px 0;'>
|
||||||
|
<strong>{$cleanInviter}</strong> heeft u uitgenodigd om toegang te krijgen tot de project- en dagplanner van <strong>Fociz</strong>.
|
||||||
|
</p>
|
||||||
|
<div style='background: rgba(99, 102, 241, 0.08); border: 1px solid rgba(99, 102, 241, 0.25); border-radius: 12px; padding: 14px 18px; margin: 18px 0;'>
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
||||||
|
<tr>
|
||||||
|
<td style='font-size: 13px; color: #94a3b8;'>E-mailadres:</td>
|
||||||
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0;'>{$email}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Toegewezen rol:</td>
|
||||||
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #818cf8; padding-top: 6px;'>{$roleLabel}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style='margin: 0 0 14px 0;'>
|
||||||
|
Klik op onderstaande knop om uw account te activeren, uw wachtwoord in te stellen en de verplichte tweestapsverificatie (2FA) te koppelen.
|
||||||
|
</p>
|
||||||
|
";
|
||||||
|
|
||||||
|
$html = self::renderEmailTemplate([
|
||||||
|
'title' => $title,
|
||||||
|
'preheader' => $preheader,
|
||||||
|
'badge' => 'TEAM UITNODIGING',
|
||||||
|
'content_html' => $contentHtml,
|
||||||
|
'cta_text' => 'Account Activeren & Inloggen',
|
||||||
|
'cta_url' => $inviteUrl,
|
||||||
|
'cta_subtext' => '⏱️ Deze uitnodigingslink is 7 dagen geldig.',
|
||||||
|
'security_notice' => 'Fociz maakt gebruik van tweestapsverificatie (2FA). Houd een authenticator app zoals Google Authenticator of Microsoft Authenticator bij de hand.'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::send($email, '', "Uitnodiging voor Fociz door {$cleanInviter}", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verstuurt een test e-mail ter verificatie van de SMTP verbinding
|
||||||
|
*/
|
||||||
|
public static function sendTestEmail(string $email, ?string $testerName = null): array {
|
||||||
|
$cleanName = !empty($testerName) ? htmlspecialchars($testerName) : 'Beheerder';
|
||||||
|
$title = "Fociz Mail Server Test Succesvol!";
|
||||||
|
$preheader = "De SMTP mailserver koppeling met mail.fociz.nl functioneert naar behoren.";
|
||||||
|
$smtpHost = defined('SMTP_HOST') ? SMTP_HOST : 'mail.fociz.nl';
|
||||||
|
$smtpPort = defined('SMTP_PORT') ? SMTP_PORT : 587;
|
||||||
|
$fromEmail = defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl';
|
||||||
|
$currentTime = date('d-m-Y H:i:s');
|
||||||
|
|
||||||
|
$contentHtml = "
|
||||||
|
<p style='margin: 0 0 14px 0;'>Beste <strong>{$cleanName}</strong>,</p>
|
||||||
|
<p style='margin: 0 0 14px 0;'>
|
||||||
|
Gefeliciteerd! Als u dit bericht leest, is de verbinding met de Fociz SMTP mailserver volledig geslaagd en operationeel.
|
||||||
|
</p>
|
||||||
|
<div style='background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.25); border-radius: 12px; padding: 14px 18px; margin: 18px 0;'>
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
||||||
|
<tr>
|
||||||
|
<td style='font-size: 13px; color: #94a3b8;'>SMTP Server:</td>
|
||||||
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0;'>{$smtpHost}:{$smtpPort}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Verzendadres:</td>
|
||||||
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #10b981; padding-top: 6px;'>{$fromEmail}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Tijdstip:</td>
|
||||||
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0; padding-top: 6px;'>{$currentTime}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style='margin: 0;'>
|
||||||
|
Wachtwoord-vergeten e-mails, teamuitnodigingen en notificaties kunnen nu veilig en betrouwbaar worden verzonden.
|
||||||
|
</p>
|
||||||
|
";
|
||||||
|
|
||||||
|
$html = self::renderEmailTemplate([
|
||||||
|
'title' => $title,
|
||||||
|
'preheader' => $preheader,
|
||||||
|
'badge' => 'VERIFICATIE TEST',
|
||||||
|
'content_html' => $contentHtml,
|
||||||
|
'cta_text' => 'Naar Fociz Dashboard',
|
||||||
|
'cta_url' => (defined('APP_URL') ? APP_URL : 'https://www.fociz.nl') . '/admin.php',
|
||||||
|
'security_notice' => 'Dit is een diagnostische testmail verzonden vanuit het Fociz Beheerpaneel.'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::send($email, $testerName ?? '', "Fociz Mail Server Test Succesvol ({$currentTime})", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eenvoudige converter van HTML naar schone platte tekst voor e-mail fallbacks
|
||||||
|
*/
|
||||||
|
private static function htmlToPlainText(string $html): string {
|
||||||
|
$text = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $html);
|
||||||
|
$text = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $text);
|
||||||
|
$text = strip_tags($text, '<a><p><br><h1><h2><h3><li>');
|
||||||
|
$text = preg_replace('/<br\s*\/?>/i', "\n", $text);
|
||||||
|
$text = preg_replace('/<\/(p|h1|h2|h3|li)>/i', "\n\n", $text);
|
||||||
|
$text = strip_tags($text);
|
||||||
|
$text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
|
||||||
|
return trim(preg_replace("/[\r\n]{3,}/", "\n\n", $text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/includes/auth.php';
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
require_once __DIR__ . '/includes/mailer.php';
|
||||||
$adminUser = require_admin();
|
$adminUser = require_admin();
|
||||||
$currentUser = $adminUser;
|
$currentUser = $adminUser;
|
||||||
$db = get_db_connection();
|
$db = get_db_connection();
|
||||||
@@ -29,10 +30,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
|||||||
$token = create_invite($adminUser['id'], $email, $role);
|
$token = create_invite($adminUser['id'], $email, $role);
|
||||||
$baseUrl = rtrim(APP_URL, '/');
|
$baseUrl = rtrim(APP_URL, '/');
|
||||||
$generatedLink = "{$baseUrl}/auth/invite.php?token={$token}";
|
$generatedLink = "{$baseUrl}/auth/invite.php?token={$token}";
|
||||||
$message = "Uitnodigingslink succesvol aangemaakt!";
|
|
||||||
|
// Verstuur de gestylede uitnodigingsmail direct via SMTP
|
||||||
|
$mailResult = FocizMailer::sendInvitationEmail($email, $adminUser['name'], $role, $generatedLink);
|
||||||
|
if ($mailResult['success']) {
|
||||||
|
$message = "Uitnodigingslink succesvol aangemaakt én per e-mail verzonden naar {$email}!";
|
||||||
|
} else {
|
||||||
|
$message = "Uitnodiging aangemaakt! Let op: de e-mail kon niet worden verzonden (" . htmlspecialchars($mailResult['error'] ?? 'serverfout') . "). U kunt de link handmatig kopiëren.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uitnodiging opnieuw verzenden per e-mail
|
||||||
|
if (isset($_GET['resend_invite'])) {
|
||||||
|
$inviteId = (int)$_GET['resend_invite'];
|
||||||
|
$stmtInv = $db->prepare("SELECT * FROM invites WHERE id = ? AND used_at IS NULL AND expires_at > NOW()");
|
||||||
|
$stmtInv->execute([$inviteId]);
|
||||||
|
$inv = $stmtInv->fetch();
|
||||||
|
if ($inv) {
|
||||||
|
$baseUrl = rtrim(APP_URL, '/');
|
||||||
|
$link = "{$baseUrl}/auth/invite.php?token={$inv['token']}";
|
||||||
|
$mailResult = FocizMailer::sendInvitationEmail($inv['email'], $adminUser['name'], $inv['role'], $link);
|
||||||
|
if ($mailResult['success']) {
|
||||||
|
$message = "Uitnodiging succesvol opnieuw per e-mail verzonden naar {$inv['email']}!";
|
||||||
|
} else {
|
||||||
|
$error = "Fout bij opnieuw verzenden van uitnodiging: " . ($mailResult['error'] ?? 'Onbekende fout');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$error = "Uitnodiging niet gevonden of reeds verlopen/geactiveerd.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Uitnodiging intrekken
|
// Uitnodiging intrekken
|
||||||
if (isset($_GET['delete_invite'])) {
|
if (isset($_GET['delete_invite'])) {
|
||||||
@@ -252,9 +280,17 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
</td>
|
</td>
|
||||||
<td class="p-3 text-slate-500 dark:text-slate-400"><?= date('d-m-Y H:i', strtotime($inv['expires_at'])) ?></td>
|
<td class="p-3 text-slate-500 dark:text-slate-400"><?= date('d-m-Y H:i', strtotime($inv['expires_at'])) ?></td>
|
||||||
<td class="p-3 text-right">
|
<td class="p-3 text-right">
|
||||||
<a href="/users.php?delete_invite=<?= $inv['id'] ?>" onclick="return confirm('Uitnodiging intrekken?')" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
|
<div class="flex items-center justify-end space-x-2">
|
||||||
|
<?php if (!$isUsed && !$isExpired): ?>
|
||||||
|
<a href="/users.php?resend_invite=<?= $inv['id'] ?>" title="Uitnodigingsmail opnieuw verzenden" class="inline-flex items-center space-x-1 px-2 py-1 bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/40 dark:hover:bg-indigo-900/60 text-indigo-600 dark:text-indigo-400 rounded-lg text-[11px] font-medium transition cursor-pointer">
|
||||||
|
<i data-lucide="mail" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Opnieuw mailen</span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="/users.php?delete_invite=<?= $inv['id'] ?>" onclick="return confirm('Uitnodiging intrekken?')" title="Uitnodiging intrekken" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
|
||||||
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
|
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user