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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>•</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user