Add password reset functionality and SMTP mailer class #31

Merged
NiekRabelink merged 1 commits from dev into main 2026-09-10 08:01:41 +00:00
10 changed files with 1248 additions and 5 deletions
+86
View File
@@ -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') ?> &lt;<?= htmlspecialchars(defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl') ?>&gt;</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) -->
+207
View File
@@ -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 &bull; Fociz 2026
</div>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
</body>
</html>
+4 -1
View File
@@ -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>
+280
View File
@@ -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 &bull; 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>
+10
View File
@@ -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"
+12
View File
@@ -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
// ========================================================================= // =========================================================================
+1
View File
@@ -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 {
+11
View File
@@ -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,
+597
View File
@@ -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} &rarr;
</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} &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;
</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;'>
&copy; {$currentYear} Fociz &bull; Veilige Project- &amp; 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));
}
}
+40 -4
View File
@@ -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,11 +30,38 @@ 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'])) {
$inviteId = (int)$_GET['delete_invite']; $inviteId = (int)$_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">
<i data-lucide="trash-2" class="w-4 h-4 inline"></i> <?php if (!$isUsed && !$isExpired): ?>
</a> <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>
</a>
</div>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>