Add roadmap management feature with database schema and user management

- Implemented roadmap.php for displaying and managing roadmap items with filters for quarter and status.
- Created schema.sql to define the database structure for users, invites, daily plans, tasks, brainstorm sessions, brainstorm cards, roadmap items, and integrations.
- Developed users.php for admin panel to manage team members and invitations, including functionality to create and revoke invites.
This commit is contained in:
2026-09-08 12:47:11 +02:00
commit 9da056b480
25 changed files with 3737 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* Authenticatie & Autorisatie Guard voor Fociz
*/
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/totp.php';
function start_secure_session(): void {
if (session_status() === PHP_SESSION_NONE) {
// Veilige sessiecookie parameters
$isSecure = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
session_set_cookie_params([
'lifetime' => 7 * 24 * 60 * 60, // 7 dagen
'path' => '/',
'domain' => '',
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Lax'
]);
session_start();
}
}
/**
* Controleert of er al een beheerder bestaat in MariaDB
*/
function has_admin_user(): bool {
$db = get_db_connection();
$stmt = $db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'");
return ((int)$stmt->fetchColumn()) > 0;
}
/**
* Haalt de huidige ingelogde gebruiker op uit de database
*/
function get_logged_in_user(): ?array {
start_secure_session();
if (empty($_SESSION['user_id']) || empty($_SESSION['is_2fa_verified'])) {
return null;
}
$db = get_db_connection();
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
return $user ?: null;
}
/**
* Beschermt pagina's: dwingt login + 2FA af
*/
function require_auth(): array {
start_secure_session();
// 1. Controleer of eerste setup nodig is
if (!has_admin_user()) {
header('Location: /auth/setup.php');
exit;
}
// 2. Controleer of gebruiker ingelogd én 2FA geverifieerd is
$user = get_logged_in_user();
if (!$user) {
header('Location: /auth/login.php');
exit;
}
return $user;
}
/**
* Dwingt beheerder (ADMIN) rol af
*/
function require_admin(): array {
$user = require_auth();
if ($user['role'] !== 'ADMIN') {
header('Location: /planner.php');
exit;
}
return $user;
}
/**
* Maakt een nieuwe uitnodiging aan voor een collega
*/
function create_invite(int $createdById, string $email, string $role = 'MEMBER'): string {
$db = get_db_connection();
$token = bin2hex(random_bytes(24));
$expiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
$stmt = $db->prepare("INSERT INTO invites (token, email, role, expires_at, created_by_id) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$token, strtolower(trim($email)), $role, $expiresAt, $createdById]);
return $token;
}
/**
* Valideert een invite token
*/
function validate_invite(string $token): ?array {
$db = get_db_connection();
$stmt = $db->prepare("
SELECT i.*, u.name as inviter_name, u.email as inviter_email
FROM invites i
JOIN users u ON i.created_by_id = u.id
WHERE i.token = ?
");
$stmt->execute([$token]);
$invite = $stmt->fetch();
if (!$invite) return null;
if ($invite['used_at'] !== null) return null;
if (strtotime($invite['expires_at']) < time()) return null;
return $invite;
}
/**
* CSRF token helpers
*/
function get_csrf_token(): string {
start_secure_session();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token(?string $token): bool {
start_secure_session();
return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
+24
View File
@@ -0,0 +1,24 @@
</main>
<footer class="border-t border-slate-800/80 py-6 text-center text-xs text-slate-500">
<div class="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row items-center justify-between gap-2">
<div class="flex items-center space-x-2">
<span class="font-bold text-slate-400">Fociz</span>
<span>&bull;</span>
<span>Focus & Structuur Systeem</span>
</div>
<div>
<span>Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
</div>
</div>
</footer>
<script>
// Lucide Icons initialiseren
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
</script>
</body>
</html>
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* Header & Navigatiebalk voor Fociz
*/
require_once __DIR__ . '/auth.php';
$currentUser = $currentUser ?? get_logged_in_user();
$currentPage = basename($_SERVER['PHP_SELF']);
?>
<!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 ?? 'Fociz — Focus, Dagplanning & Roadmap') ?></title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#f0fdf4',
500: '#10b981',
600: '#059669',
700: '#047857',
}
}
}
}
}
</script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
body {
background-color: #090d16;
color: #f1f5f9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
</style>
</head>
<body class="min-h-screen flex flex-col antialiased selection:bg-emerald-500 selection:text-white">
<?php if ($currentUser): ?>
<header class="sticky top-0 z-40 bg-[#0c121e]/90 backdrop-blur-md border-b border-slate-800/80">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Brand Logo -->
<div class="flex items-center space-x-6">
<a href="/planner.php" class="flex items-center space-x-2.5 group">
<div class="w-9 h-9 rounded-xl bg-gradient-to-tr from-emerald-600 to-teal-400 flex items-center justify-center text-slate-950 font-black text-lg shadow-md shadow-emerald-500/20 group-hover:scale-105 transition">
F
</div>
<div class="flex flex-col">
<span class="text-xl font-bold tracking-tight text-white leading-none">Fociz</span>
<span class="text-[10px] text-emerald-400 tracking-wider font-semibold uppercase">Focus & Structuur</span>
</div>
</a>
<!-- Nav Tabs -->
<nav class="hidden md:flex items-center space-x-1">
<a href="/planner.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'planner.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>Dagplanning</span>
</a>
<a href="/brainstorm.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="lightbulb" class="w-4 h-4"></i>
<span>Brainstorm</span>
</a>
<a href="/roadmap.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'roadmap.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Roadmap</span>
</a>
<a href="/integrations.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'integrations.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="layers" class="w-4 h-4"></i>
<span>Integraties</span>
</a>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/users.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'users.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="users" class="w-4 h-4"></i>
<span>Team & Invites</span>
</a>
<?php endif; ?>
</nav>
</div>
<!-- Right side: User & 2FA Status -->
<div class="flex items-center space-x-3">
<div class="hidden sm:flex items-center space-x-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/25 text-emerald-400 text-xs font-medium">
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i>
<span>2FA Actief</span>
</div>
<div class="text-right hidden sm:block">
<div class="text-xs font-semibold text-slate-200"><?= htmlspecialchars($currentUser['name']) ?></div>
<div class="text-[10px] text-slate-500">
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?> • <?= htmlspecialchars($currentUser['email']) ?>
</div>
</div>
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 transition">
<i data-lucide="log-out" class="w-4 h-4"></i>
</a>
</div>
</div>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-800/60 text-xs">
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="calendar" class="w-4 h-4 mb-0.5"></i>
<span>Planning</span>
</a>
<a href="/brainstorm.php" class="flex flex-col items-center py-1 <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="lightbulb" class="w-4 h-4 mb-0.5"></i>
<span>Brainstorm</span>
</a>
<a href="/roadmap.php" class="flex flex-col items-center py-1 <?= $currentPage === 'roadmap.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="map" class="w-4 h-4 mb-0.5"></i>
<span>Roadmap</span>
</a>
<a href="/integrations.php" class="flex flex-col items-center py-1 <?= $currentPage === 'integrations.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="layers" class="w-4 h-4 mb-0.5"></i>
<span>Koppelingen</span>
</a>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/users.php" class="flex flex-col items-center py-1 <?= $currentPage === 'users.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="users" class="w-4 h-4 mb-0.5"></i>
<span>Team</span>
</a>
<?php endif; ?>
</div>
</div>
</header>
<?php endif; ?>
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* Pure PHP TOTP (RFC 6238) & 2FA Implementatie
* Volledig compatibel met Google Authenticator, Microsoft Authenticator & 1Password
*/
class TOTP {
private static string $base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/**
* Genereert een willekeurige 16-teken Base32 geheime sleutel
*/
public static function generateSecret(int $length = 16): string {
$secret = '';
$bytes = random_bytes($length);
for ($i = 0; $i < $length; $i++) {
$secret .= self::$base32Chars[ord($bytes[$i]) % 32];
}
return $secret;
}
/**
* Decodeert een Base32 string naar binaire data
*/
private static function base32Decode(string $b32): string {
$b32 = strtoupper(trim($b32));
$buffer = 0;
$bufferBits = 0;
$binary = '';
for ($i = 0; $i < strlen($b32); $i++) {
$char = $b32[$i];
$val = strpos(self::$base32Chars, $char);
if ($val === false) continue; // Negeer spaties/ongeldige tekens
$buffer = ($buffer << 5) | $val;
$bufferBits += 5;
if ($bufferBits >= 8) {
$bufferBits -= 8;
$binary .= chr(($buffer >> $bufferBits) & 0xFF);
}
}
return $binary;
}
/**
* Berekent de 6-cijferige TOTP code voor een bepaald tijdsinterval
*/
public static function getCode(string $secret, ?int $timestamp = null): string {
if ($timestamp === null) {
$timestamp = time();
}
$timeCounter = floor($timestamp / 30);
$binaryKey = self::base32Decode($secret);
// Converteer tijdsinterval naar 8-byte big-endian binaire string
$binaryTime = pack('N*', 0) . pack('N*', $timeCounter);
$hash = hash_hmac('sha1', $binaryTime, $binaryKey, true);
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
$binaryCode = (
((ord($hash[$offset]) & 0x7F) << 24) |
((ord($hash[$offset + 1]) & 0xFF) << 16) |
((ord($hash[$offset + 2]) & 0xFF) << 8) |
(ord($hash[$offset + 3]) & 0xFF)
);
$otp = $binaryCode % 1000000;
return str_pad((string)$otp, 6, '0', STR_PAD_LEFT);
}
/**
* Verifieert een ingevoerde 6-cijferige 2FA code (met 1 tijdstap speling naar voor/achter)
*/
public static function verifyCode(string $secret, string $userInputCode, int $window = 1): boolean {
$userInputCode = trim(str_replace(' ', '', $userInputCode));
if (strlen($userInputCode) !== 6 || !ctype_digit($userInputCode)) {
return false;
}
$currentTime = time();
for ($i = -$window; $i <= $window; $i++) {
$calcCode = self::getCode($secret, $currentTime + ($i * 30));
if (hash_equals($calcCode, $userInputCode)) {
return true;
}
}
return false;
}
/**
* Maakt de otpauth:// URL aan voor authenticator apps
*/
public static function getOtpAuthUrl(string $email, string $secret, string $issuer = 'Fociz'): string {
return sprintf(
'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30',
rawurlencode($issuer),
rawurlencode($email),
$secret,
rawurlencode($issuer)
);
}
/**
* Genereert een QR code afbeelding URL (via QuickChart HTTPS)
*/
public static function getQrCodeUrl(string $otpAuthUrl, int $size = 220): string {
return sprintf(
'https://quickchart.io/qr?text=%s&size=%d&margin=1&ecLevel=M',
urlencode($otpAuthUrl),
$size
);
}
/**
* Genereert een lijst met eenmalige herstelcodes
*/
public static function generateBackupCodes(int $count = 8): array {
$codes = [];
for ($i = 0; $i < $count; $i++) {
$bytes = bin2hex(random_bytes(4));
$codes[] = strtoupper(substr($bytes, 0, 4) . '-' . substr($bytes, 4, 4));
}
return $codes;
}
}