Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 091de8bf95 | |||
| 3312c08ea1 | |||
| 39a41fa207 | |||
| 78369a44c2 | |||
| 07988b3e1d | |||
| 0c466972c2 | |||
| dc6b55e4f9 | |||
| ef116be9b8 | |||
| 65d5a4d4a3 | |||
| a4824bb440 | |||
| e45f565365 | |||
| 3dc09a11a7 | |||
| 86041b665d | |||
| f18f7d5794 | |||
| 45600040ea | |||
| 660f196c60 | |||
| 32974cdb31 | |||
| 8ddae4d883 | |||
| d4e4b69e02 | |||
| 65f81d620c | |||
| 0b5ca54db9 | |||
| 20d8f93f77 | |||
| bda7b311e3 | |||
| 85850f47ad | |||
| 45a883319a | |||
| 460fa967ee | |||
| e9f54cbe72 | |||
| 210ecc7b18 | |||
| 2d89e19114 | |||
| 6bc351aaa4 | |||
| 1ad383d27b | |||
| 8498d82e10 | |||
| a038d25489 | |||
| 97d6085c9d | |||
| 4a9ba4d76a | |||
| b800279cd2 | |||
| e08b304c38 | |||
| 687b929170 | |||
| 830fdf1330 | |||
| 2750fd0681 | |||
| d65463dab8 | |||
| 8cff08f253 | |||
| 843b75334e | |||
| 9cd457971b | |||
| 1358b6a7e9 | |||
| aa23ccdd73 | |||
| e3af7c7aa5 | |||
| cb8246dba9 | |||
| f992ca1ec9 | |||
| 5c4c53922b | |||
| 7a85f612b1 | |||
| ee12d54350 | |||
| e8a6667abd | |||
| 7e7f071f04 | |||
| 3595d58763 | |||
| bf0d2f3bca | |||
| 24fbfb9d82 | |||
| 6a4981744f | |||
| 5a31f0948a | |||
| 740b5343c4 | |||
| cd6071902d | |||
| 32d27cd457 | |||
| 31caf540a3 | |||
| 4eaae64eb6 | |||
| 6987cab4f8 | |||
| 89b53f6428 | |||
| fd59cd69c1 | |||
| 35fcde49c3 | |||
| 29874f184b | |||
| f63ea740bf | |||
| 0bafe0afe9 | |||
| 5f15eb67fe | |||
| 880c1bf9bb | |||
| 94d6325693 | |||
| 7c27949cc2 | |||
| a0d99914e6 | |||
| af80ffdb14 | |||
| 1c3541e602 | |||
| 5e35f5152d | |||
| 845216e554 | |||
| 797aaa76c7 | |||
| 0ad186e95f | |||
| 8714038db2 | |||
| bc33c0bd1a | |||
| 582ba9871f | |||
| 32632dab91 | |||
| 9442dd7b38 | |||
| ab03552c29 | |||
| 046d344003 | |||
| 364fd776c4 | |||
| a1e9dd19ae | |||
| a514eb6521 | |||
| d87a62e1f2 | |||
| f620f20d35 | |||
| f389f67774 | |||
| bb0bf658ed | |||
| a1f2937504 | |||
| 09ab527a93 | |||
| 51658e0aa9 | |||
| 5a5456ffe0 | |||
| 8e62e73d46 | |||
| bcd85f131b | |||
| 41d3733f69 | |||
| 245d09e62c | |||
| 8095a63f31 | |||
| 987bcf5684 | |||
| 30be1dfee9 | |||
| fc1a211c73 | |||
| 38701bf06a | |||
| ad06c67235 | |||
| deba8463b8 | |||
| 7fe7c0f51a | |||
| 7461231879 | |||
| 6ebaf027bf | |||
| d870e643dc | |||
| d15303ff6c | |||
| bc6fdd249e | |||
| 35ce20cd59 | |||
| e3a4d862bd | |||
| e012d6d21c | |||
| 2dc0afc7a0 | |||
| dc740fb94d | |||
| 0942a94edd | |||
| e841d29b63 | |||
| 88ec1418cf | |||
| 0436c076e3 | |||
| 6a9935e5cf | |||
| cc5d4a027f | |||
| 0092cc17ef | |||
| b3dfbf4406 | |||
| 939e0518df | |||
| 61043cd61c | |||
| 4f7db2ec83 | |||
| 795e41561c | |||
| 29b8ce9211 | |||
| 411fa79d7e | |||
| cc439c8d0c | |||
| 6060f39566 | |||
| cf0ceeca82 | |||
| 4026efc816 | |||
| 55618ffaed | |||
| c3001cdd0a | |||
| 29adb48dd9 | |||
| 5ce08443cc | |||
| bf944e063b | |||
| c0e0dd5947 | |||
| f1d5c55c03 | |||
| b608e21ac2 | |||
| 191b5115b7 | |||
| fa4c8c0e17 | |||
| 144721585c | |||
| 54ecc548bc | |||
| a92b4d0f4c | |||
| 2bc6c8c55d | |||
| e161c72fc4 | |||
| 26076f92f8 | |||
| 72ad1fb70e | |||
| 0c6fa507b6 | |||
| 5310b87fe5 | |||
| 35b8fa24f5 | |||
| 291fb761c7 | |||
| df6dadee6f | |||
| 8a930f572c | |||
| 15caa5f341 | |||
| fb38c42fe9 | |||
| c6e7a8d117 | |||
| dc0b33a278 | |||
| 25c2ffeef8 | |||
| 03c7fb3067 | |||
| 2270e5875d | |||
| 47b0d38084 | |||
| 64fbd8079d | |||
| ffc824f27c | |||
| 130a43eabc | |||
| 9c4d1e9618 | |||
| a7aad57e0b | |||
| c65d4a2525 | |||
| d4fd706eca | |||
| 08307f5fe7 | |||
| eda90dbba6 | |||
| 4163199d16 | |||
| 1a6b82b583 | |||
| 302b6fb555 | |||
| ff11f428a6 | |||
| 4fbe30b4b6 | |||
| 1aad7f5d7f | |||
| c6b322cb49 | |||
| d83704d0df | |||
| eaee4d5f1c | |||
| bcf2323124 | |||
| 963342f858 | |||
| 2062fd065a | |||
| 0b9e92eded | |||
| dfaa8ba7a8 | |||
| ed5bd6f998 | |||
| 5bdaa5fa6e | |||
| 919dbe4aed | |||
| dc28322e98 | |||
| 1b6c8e4afd | |||
| 1487b680f1 | |||
| c5629dcfaa | |||
| 4a566182ba | |||
| 64a7a3bcf0 | |||
| 4cb94d3897 | |||
| 8f191f5123 | |||
| 890cbe7446 | |||
| e879e8c134 | |||
| ca6ed7d175 | |||
| 9262b09c4d | |||
| dd738e670b | |||
| 1df22d97f9 | |||
| 5f6a6e37a0 | |||
| 59eba99eac | |||
| d2f207ffcc | |||
| a5324548ec |
@@ -1,5 +1,16 @@
|
||||
# Zorg dat omgevingsspecifieke bestanden nooit worden overschreven bij merges tussen dev en main
|
||||
config/config.php merge=ours
|
||||
includes/env_badge.php merge=ours
|
||||
config/.env.example merge=ours
|
||||
# Automatische detectie van tekstbestanden en consistente line endings (LF)
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binaire bestanden
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
# Sluit repository metadata uit bij release archieven
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
/.env
|
||||
/.env.local
|
||||
*.local
|
||||
!config/.env
|
||||
|
||||
# Systeem & Editor
|
||||
.DS_Store
|
||||
@@ -8,5 +10,9 @@ Thumbs.db
|
||||
*.log
|
||||
.vscode/
|
||||
.idea/
|
||||
config/.env.example
|
||||
config/config.php
|
||||
.continue/
|
||||
.continueignore
|
||||
|
||||
# Uploads
|
||||
/assets/uploads/*
|
||||
!/assets/uploads/**/.gitkeep
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# =========================================================================
|
||||
# Fociz Pentest & Security Hardening (.htaccess)
|
||||
# =========================================================================
|
||||
|
||||
# 1. Schakel directory listing uit
|
||||
Options -Indexes
|
||||
|
||||
# 2. Blokkeer directe toegang tot verborgen systeembestanden (.env, .git, etc.)
|
||||
<FilesMatch "^\.">
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
# 3. Blokkeer directe toegang tot gevoelige bestandsformaten
|
||||
<FilesMatch "\.(sql|log|example|md|json|lock|bak|conf)$">
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
# 4. Beveiligingsheaders
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
Header unset X-Powered-By
|
||||
</IfModule>
|
||||
@@ -0,0 +1,679 @@
|
||||
<?php
|
||||
/**
|
||||
* AJAX API voor AI Co-Pilot (Release v1.5)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/ai.php';
|
||||
require_once __DIR__ . '/../includes/settings.php';
|
||||
|
||||
$user = require_auth();
|
||||
$db = get_db_connection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||
|
||||
try {
|
||||
// 1. Auto-categoriseer kaartjes in een brainstormsessie
|
||||
if ($action === 'categorize_cards') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
|
||||
if (!$sessionId) {
|
||||
echo json_encode(['error' => 'Sessie ID ontbreekt']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtSession->execute([$sessionId]);
|
||||
$session = $stmtSession->fetch();
|
||||
|
||||
if (!$session) {
|
||||
echo json_encode(['error' => 'Sessie niet gevonden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtCards = $db->prepare("SELECT id, content, category FROM brainstorm_cards WHERE session_id = ?");
|
||||
$stmtCards->execute([$sessionId]);
|
||||
$cards = $stmtCards->fetchAll();
|
||||
|
||||
if (empty($cards)) {
|
||||
echo json_encode(['error' => 'Er zijn nog geen kaartjes op het canvas om te categoriseren']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$validCategories = ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins'];
|
||||
$updateStmt = $db->prepare("UPDATE brainstorm_cards SET category = ? WHERE id = ? AND session_id = ?");
|
||||
$updatedCount = 0;
|
||||
|
||||
foreach ($aiRes['categorized'] as $item) {
|
||||
$cardId = (int)($item['id'] ?? 0);
|
||||
$newCat = trim($item['category'] ?? '');
|
||||
if ($cardId > 0 && in_array($newCat, $validCategories)) {
|
||||
$updateStmt->execute([$newCat, $cardId, $sessionId]);
|
||||
$updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'updated' => $updatedCount]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Automatisch ideeën genereren voor een sessie
|
||||
if ($action === 'generate_ideas') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
$count = min(6, max(2, (int)($data['count'] ?? 4)));
|
||||
$fullBoard = !empty($data['full_board']); // Genereren over alle 4 categorieën o.b.v. briefing
|
||||
|
||||
if (!$sessionId) {
|
||||
echo json_encode(['error' => 'Sessie ID ontbreekt']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtSession->execute([$sessionId]);
|
||||
$session = $stmtSession->fetch();
|
||||
|
||||
if (!$session) {
|
||||
echo json_encode(['error' => 'Sessie niet gevonden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$authorName = 'AI Co-Pilot';
|
||||
$createdCards = [];
|
||||
|
||||
$colorMap = [
|
||||
'Ideeën' => 'amber',
|
||||
'Knelpunten' => 'rose',
|
||||
'Oplossingen' => 'teal',
|
||||
'Quick Wins' => 'indigo',
|
||||
];
|
||||
|
||||
if ($fullBoard || !empty($session['description'])) {
|
||||
// Gebruik de volledige board generator die alle 4 categorieën vult aan de hand van de briefing
|
||||
$aiRes = fociz_ai_generate_session_board(
|
||||
$session['title'],
|
||||
$session['theme'] ?? 'Algemeen',
|
||||
$session['description'] ?? '',
|
||||
$user['id']
|
||||
);
|
||||
|
||||
if ($aiRes['success'] && !empty($aiRes['cards'])) {
|
||||
$insertStmt = $db->prepare("
|
||||
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
");
|
||||
|
||||
foreach ($aiRes['cards'] as $cardItem) {
|
||||
$cText = trim($cardItem['content'] ?? '');
|
||||
$cCat = trim($cardItem['category'] ?? 'Ideeën');
|
||||
if (!isset($colorMap[$cCat])) {
|
||||
$cCat = 'Ideeën';
|
||||
}
|
||||
$cColor = $colorMap[$cCat];
|
||||
|
||||
if (!empty($cText)) {
|
||||
$insertStmt->execute([$sessionId, $cText, $cCat, $cColor, $authorName]);
|
||||
$createdCards[] = [
|
||||
'id' => (int)$db->lastInsertId(),
|
||||
'content' => $cText,
|
||||
'category' => $cCat,
|
||||
'color' => $cColor,
|
||||
'author_name' => $authorName,
|
||||
'votes' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'full_board']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: reguliere generatie
|
||||
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id'], $session['description'] ?? '');
|
||||
|
||||
$ideas = $aiRes['ideas'] ?? [];
|
||||
if (empty($ideas)) {
|
||||
$ideas = [
|
||||
"Gebruikerservaring en flow optimaliseren voor " . $session['title'],
|
||||
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
|
||||
"Koppelingen en integraties verkennen voor snellere afhandeling",
|
||||
"Feedback- en validatielus inrichten met eindgebruikers",
|
||||
];
|
||||
}
|
||||
|
||||
$insertStmt = $db->prepare("
|
||||
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
|
||||
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
|
||||
");
|
||||
|
||||
foreach ($ideas as $ideaText) {
|
||||
$ideaText = trim($ideaText);
|
||||
if (!empty($ideaText)) {
|
||||
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
|
||||
$createdCards[] = [
|
||||
'id' => (int)$db->lastInsertId(),
|
||||
'content' => $ideaText,
|
||||
'category' => 'Ideeën',
|
||||
'color' => 'amber',
|
||||
'author_name' => $authorName,
|
||||
'votes' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'ideas']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Brainstorm notitie omzetten naar Roadmap Item
|
||||
if ($action === 'summarize_to_roadmap') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$content = trim($data['content'] ?? '');
|
||||
$sessionTitle = trim($data['session_title'] ?? '');
|
||||
|
||||
if (empty($content)) {
|
||||
echo json_encode(['error' => 'Geen inhoud opgegeven om samen te vatten']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'item' => $aiRes['item']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3b. AI Brainstorm Idee Uitwerken tot concrete behapbare items
|
||||
if ($action === 'breakdown_idea') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$content = trim($data['content'] ?? '');
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
$sessionTitle = trim($data['session_title'] ?? '');
|
||||
$theme = trim($data['theme'] ?? '');
|
||||
|
||||
if (empty($content)) {
|
||||
echo json_encode(['error' => 'Geen inhoud opgegeven om uit te werken.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($sessionId > 0 && (empty($sessionTitle) || empty($theme))) {
|
||||
$stmtS = $db->prepare("SELECT title, theme FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtS->execute([$sessionId]);
|
||||
$sRow = $stmtS->fetch();
|
||||
if ($sRow) {
|
||||
if (empty($sessionTitle)) $sessionTitle = $sRow['title'] ?? '';
|
||||
if (empty($theme)) $theme = $sRow['theme'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_breakdown_idea($content, $sessionTitle, $theme, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'summary' => $aiRes['summary'],
|
||||
'items' => $aiRes['items'],
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3c. Opgesplitste AI items exporteren naar Kanban Bord, Tijdlijn of Dagplanner
|
||||
if ($action === 'save_breakdown_items') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$destination = trim($data['destination'] ?? 'kanban'); // 'kanban', 'timeline', 'planner'
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
if (empty($items) || !is_array($items)) {
|
||||
echo json_encode(['error' => 'Geen items geselecteerd om op te slaan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$isAdmin = ($user['role'] === 'ADMIN');
|
||||
$createdCount = 0;
|
||||
|
||||
if ($destination === 'kanban' || $destination === 'timeline') {
|
||||
$boardId = (int)($data['board_id'] ?? 0);
|
||||
$laneId = (int)($data['lane_id'] ?? 0);
|
||||
|
||||
if ($boardId <= 0) {
|
||||
// Pak het eerste actieve bord van de gebruiker
|
||||
if ($isAdmin) {
|
||||
$boardId = (int)$db->query("SELECT id FROM boards WHERE is_archived = 0 ORDER BY id ASC LIMIT 1")->fetchColumn();
|
||||
} else {
|
||||
$stmtB = $db->prepare("SELECT b.id FROM boards b JOIN board_members bm ON b.id = bm.board_id WHERE bm.user_id = ? AND b.is_archived = 0 ORDER BY b.id ASC LIMIT 1");
|
||||
$stmtB->execute([$user['id']]);
|
||||
$boardId = (int)$stmtB->fetchColumn();
|
||||
}
|
||||
}
|
||||
|
||||
if ($boardId <= 0) {
|
||||
echo json_encode(['error' => 'Geen actief Kanban-bord gevonden om taken aan toe te voegen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Toegangscontrole
|
||||
if (!$isAdmin) {
|
||||
$stmtCheck = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmtCheck->execute([$boardId, $user['id']]);
|
||||
if ((int)$stmtCheck->fetchColumn() === 0) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Geen toegang tot dit bord.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Als lane_id niet geldig is, pak de eerste kolom (bijv. 'To Do' of 'Backlog')
|
||||
if ($laneId <= 0) {
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
|
||||
$stmtLane->execute([$boardId]);
|
||||
$laneId = (int)$stmtLane->fetchColumn();
|
||||
}
|
||||
|
||||
if ($laneId <= 0) {
|
||||
echo json_encode(['error' => 'Het geselecteerde bord heeft geen kolommen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Datumbereik voor Tijdlijn
|
||||
$startDate = !empty($data['start_date']) ? trim($data['start_date']) : date('Y-m-d');
|
||||
$endDate = !empty($data['end_date']) ? trim($data['end_date']) : date('Y-m-d', strtotime('+3 days'));
|
||||
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtInsertCard = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
|
||||
$stmtSchedule = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
|
||||
VALUES (?, 'DAILY_RANGE', ?, ?, '09:00', '17:00', '1,2,3,4,5')
|
||||
");
|
||||
|
||||
$priorityMap = ['P1' => 'HIGH', 'P2' => 'MEDIUM', 'P3' => 'LOW'];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$title = trim($item['title'] ?? '');
|
||||
if (empty($title)) continue;
|
||||
|
||||
$desc = trim($item['description'] ?? '');
|
||||
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
|
||||
$priority = $priorityMap[$rawPri] ?? ($rawPri === 'HIGH' || $rawPri === 'LOW' ? $rawPri : 'MEDIUM');
|
||||
$color = trim($item['color'] ?? 'indigo');
|
||||
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
|
||||
$stmtInsertCard->execute([
|
||||
$boardId,
|
||||
$laneId,
|
||||
$title,
|
||||
$desc,
|
||||
$user['id'],
|
||||
$priority,
|
||||
$color,
|
||||
$maxPos + 1,
|
||||
$user['id'],
|
||||
]);
|
||||
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
|
||||
if ($destination === 'timeline' && $cardId > 0) {
|
||||
$stmtSchedule->execute([$cardId, $startDate, $endDate]);
|
||||
}
|
||||
|
||||
$createdCount++;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'destination' => $destination,
|
||||
'created_count' => $createdCount,
|
||||
'board_id' => $boardId,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($destination === 'planner') {
|
||||
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : date('Y-m-d');
|
||||
|
||||
// Zoek of maak daily plan
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $planDate]);
|
||||
$planId = (int)$stmtPlan->fetchColumn();
|
||||
|
||||
if ($planId <= 0) {
|
||||
$stmtNewPlan = $db->prepare("INSERT INTO daily_plans (user_id, plan_date, focus_goal) VALUES (?, ?, 'Brainstorm actiepunten oppakken')");
|
||||
$stmtNewPlan->execute([$user['id'], $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
$stmtInsertTask = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, duration_minutes, status, source)
|
||||
VALUES (?, ?, ?, ?, ?, 'TODO', 'BRAINSTORM')
|
||||
");
|
||||
|
||||
foreach ($items as $item) {
|
||||
$title = trim($item['title'] ?? '');
|
||||
if (empty($title)) continue;
|
||||
|
||||
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
|
||||
$priority = in_array($rawPri, ['P1', 'P2', 'P3']) ? $rawPri : 'P2';
|
||||
$duration = max(15, min(240, (int)($item['estimated_minutes'] ?? 30)));
|
||||
|
||||
$stmtInsertTask->execute([
|
||||
$user['id'],
|
||||
$planId,
|
||||
$title,
|
||||
$priority,
|
||||
$duration,
|
||||
]);
|
||||
|
||||
$createdCount++;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'destination' => 'planner',
|
||||
'created_count' => $createdCount,
|
||||
'plan_date' => $planDate,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende bestemming: ' . htmlspecialchars($destination)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3d. Test AI Verbinding (Gemini Pro / OpenAI)
|
||||
if ($action === 'test_connection') {
|
||||
try {
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = !empty($rawInput) ? (json_decode($rawInput, true) ?: []) : $_POST;
|
||||
|
||||
$tempProvider = !empty($data['provider']) ? trim((string)$data['provider']) : null;
|
||||
$tempKey = !empty($data['api_key']) ? trim((string)$data['api_key']) : null;
|
||||
$tempModel = !empty($data['model']) ? trim((string)$data['model']) : null;
|
||||
|
||||
// Als er test credentials zijn meegegeven, test direct met die credentials
|
||||
if (!empty($tempKey) && $tempKey !== '••••••••••••••••') {
|
||||
$testSettings = [
|
||||
'ai_provider' => $tempProvider ?: 'gemini',
|
||||
'ai_api_key' => $tempKey,
|
||||
'ai_model' => $tempModel ?: ($tempProvider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro'),
|
||||
];
|
||||
} else {
|
||||
// Anders haal de geconfigureerde instellingen op van user of org
|
||||
$testSettings = get_ai_settings($user['id']);
|
||||
if (!empty($tempModel)) {
|
||||
$testSettings['ai_model'] = $tempModel;
|
||||
}
|
||||
if (!empty($tempProvider)) {
|
||||
$testSettings['ai_provider'] = $tempProvider;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($testSettings['ai_api_key'])) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Geen API-sleutel ingevuld of geconfigureerd om te testen.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$provider = strtolower($testSettings['ai_provider'] ?? 'gemini');
|
||||
$model = $testSettings['ai_model'] ?? ($provider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro');
|
||||
|
||||
$t0 = microtime(true);
|
||||
|
||||
$testMessages = [
|
||||
['role' => 'user', 'content' => 'Fociz connectiviteitstest. Reageer enkel met het woord OK.'],
|
||||
];
|
||||
|
||||
if ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) {
|
||||
$aiRes = fociz_ai_chat_gemini($testMessages, ['max_tokens' => 500, 'temperature' => 0.1], $testSettings);
|
||||
} else {
|
||||
$aiRes = fociz_ai_chat_openai($testMessages, ['max_tokens' => 500, 'temperature' => 0.1], $testSettings);
|
||||
}
|
||||
|
||||
$latency = round((microtime(true) - $t0) * 1000);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $aiRes['error'] ?? 'Verbindingstest mislukt.',
|
||||
'latency_ms' => $latency,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$providerName = ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) ? 'Google Gemini Pro' : 'OpenAI';
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => "Verbinding met {$providerName} is succesvol tot stand gebracht! (Response tijd: {$latency}ms)",
|
||||
'latency_ms' => $latency,
|
||||
'provider' => $provider,
|
||||
'model' => $aiRes['model'] ?? $model,
|
||||
'zero_leak' => true,
|
||||
]);
|
||||
exit;
|
||||
} catch (Throwable $testEx) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Fout tijdens verbindingstest: ' . $testEx->getMessage(),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Slimme dagindeling berekenen voor taken rondom bezette tijdblokken
|
||||
// ZERO-DATA LEAKAGE: Geen meetingsdetails of externe tickets worden verzonden!
|
||||
if ($action === 'smart_schedule') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d');
|
||||
|
||||
// Haal daily plan op
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $date]);
|
||||
$plan = $stmtPlan->fetch();
|
||||
$planId = $plan['id'] ?? 0;
|
||||
|
||||
// ZERO-DATA LEAKAGE: Sluit expliciet alle taken uit die afkomstig zijn van Jira, Mail of Teams!
|
||||
$isToday = ($date === date('Y-m-d'));
|
||||
$stmtTasks = $db->prepare("
|
||||
SELECT t.id, t.title, t.priority, t.duration_minutes
|
||||
FROM tasks t
|
||||
WHERE t.user_id = ?
|
||||
AND (t.daily_plan_id = ? " . ($isToday ? "OR t.daily_plan_id IS NULL" : "") . ")
|
||||
AND t.is_completed = 0
|
||||
AND (t.source IS NULL OR t.source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
|
||||
AND t.external_id IS NULL
|
||||
ORDER BY t.priority ASC, t.created_at ASC
|
||||
");
|
||||
$stmtTasks->execute([$user['id'], $planId]);
|
||||
$tasks = $stmtTasks->fetchAll();
|
||||
|
||||
if (empty($tasks)) {
|
||||
echo json_encode(['error' => 'Geen openstaande Fociz taken gevonden om in te delen']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ZERO-DATA LEAKAGE: Haal meetings op maar geef UITSLUITEND start/eindtijd door (geen titel/deelnemers)
|
||||
$busyBlocks = [];
|
||||
try {
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
$rawEvents = microsoft_fetch_today_events($user['id'], $date);
|
||||
foreach ($rawEvents as $ev) {
|
||||
if (!empty($ev['start']) && !empty($ev['end'])) {
|
||||
$busyBlocks[] = [
|
||||
'start' => substr($ev['start'], 0, 5),
|
||||
'end' => substr($ev['end'], 0, 5),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Microsoft niet gekoppeld
|
||||
}
|
||||
|
||||
$workdaySettings = get_workday_settings();
|
||||
|
||||
$aiRes = fociz_ai_smart_schedule($tasks, $busyBlocks, $workdaySettings, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Werk de tijdsblokken bij in MariaDB
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE tasks
|
||||
SET time_block_start = ?,
|
||||
duration_minutes = COALESCE(?, duration_minutes)
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
|
||||
$scheduledCount = 0;
|
||||
foreach ($aiRes['scheduled'] as $s) {
|
||||
$taskId = (int)($s['id'] ?? 0);
|
||||
$startTime = trim($s['time_block_start'] ?? '');
|
||||
$duration = !empty($s['duration_minutes']) ? (int)$s['duration_minutes'] : null;
|
||||
|
||||
if ($taskId > 0 && preg_match('/^\d{1,2}:\d{2}$/', $startTime)) {
|
||||
$updateStmt->execute([$startTime, $duration, $taskId, $user['id']]);
|
||||
$scheduledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'scheduled_count' => $scheduledCount]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Persoonlijke AI Dagbriefing genereren
|
||||
// ZERO-DATA LEAKAGE: Absoluut GEEN Jira, Mail of Teams data!
|
||||
if ($action === 'daily_briefing') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d');
|
||||
$today = date('Y-m-d');
|
||||
|
||||
// Haal openstaande taken van vandaag op (ZERO-DATA LEAKAGE: GEEN externe Jira/Mail taken)
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $date]);
|
||||
$planId = $stmtPlan->fetchColumn();
|
||||
|
||||
$isToday = ($date === $today);
|
||||
$taskFilter = $planId
|
||||
? "t.daily_plan_id = " . (int)$planId . ($isToday ? " OR t.daily_plan_id IS NULL" : "")
|
||||
: ($isToday ? "t.daily_plan_id IS NULL" : "1=0");
|
||||
|
||||
$stmtTasks = $db->prepare("
|
||||
SELECT id, title, priority, time_block_start, duration_minutes
|
||||
FROM tasks t
|
||||
WHERE t.user_id = ?
|
||||
AND t.is_completed = 0
|
||||
AND ({$taskFilter})
|
||||
AND (t.source IS NULL OR t.source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
|
||||
AND t.external_id IS NULL
|
||||
ORDER BY t.priority ASC, t.time_block_start ASC
|
||||
LIMIT 15
|
||||
");
|
||||
$stmtTasks->execute([$user['id']]);
|
||||
$tasks = $stmtTasks->fetchAll();
|
||||
|
||||
// Haal gisteren niet-voltooide interne taken op
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day', strtotime($date)));
|
||||
$stmtYest = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtYest->execute([$user['id'], $yesterday]);
|
||||
$yesterdayPlanId = $stmtYest->fetchColumn();
|
||||
$unfinishedYest = [];
|
||||
if ($yesterdayPlanId) {
|
||||
$stmtUnf = $db->prepare("
|
||||
SELECT title FROM tasks
|
||||
WHERE user_id = ? AND daily_plan_id = ? AND is_completed = 0
|
||||
AND (source IS NULL OR source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
|
||||
AND external_id IS NULL
|
||||
LIMIT 5
|
||||
");
|
||||
$stmtUnf->execute([$user['id'], $yesterdayPlanId]);
|
||||
$unfinishedYest = $stmtUnf->fetchAll();
|
||||
}
|
||||
|
||||
// ZERO-DATA LEAKAGE: Alleen geanonimiseerde bezette tijdblokken (geen subject/afzender)
|
||||
$busyBlocks = [];
|
||||
try {
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
$rawEvents = microsoft_fetch_today_events($user['id'], $date);
|
||||
foreach ($rawEvents as $ev) {
|
||||
if (!empty($ev['start']) && !empty($ev['end'])) {
|
||||
$busyBlocks[] = [
|
||||
'start' => substr($ev['start'], 0, 5),
|
||||
'end' => substr($ev['end'], 0, 5),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Geen agenda beschikbaar
|
||||
}
|
||||
|
||||
$aiSettings = get_ai_settings($user['id']);
|
||||
if (empty($aiSettings['ai_enabled']) || empty($aiSettings['has_key'])) {
|
||||
echo json_encode(['error' => 'AI Co-Pilot is niet geconfigureerd']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_daily_briefing(
|
||||
$user['name'] ?? 'Jij',
|
||||
$date,
|
||||
$tasks,
|
||||
$busyBlocks,
|
||||
$unfinishedYest,
|
||||
$user['id']
|
||||
);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'greeting' => $aiRes['greeting'],
|
||||
'focus_task' => $aiRes['focus_task'],
|
||||
'tip' => $aiRes['tip'],
|
||||
'warnings' => $aiRes['warnings'],
|
||||
'date' => $date,
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende actie']);
|
||||
} catch (Throwable $e) {
|
||||
error_log("api/ai.php Throwable: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Serverfout bij AI verwerking: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Kanban / Trello Bord API
|
||||
* Beheert borden, kolommen/lanes, kaarten, toewijzingen, tijdspaden & Outlook 365 integratie
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$currentUser = require_auth(true);
|
||||
$db = get_db_connection();
|
||||
ensure_tables_exist($db);
|
||||
|
||||
$userId = (int)($currentUser['id'] ?? 0);
|
||||
$userRole = $currentUser['role'] ?? 'MEMBER';
|
||||
$isAdmin = ($userRole === 'ADMIN');
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
|
||||
/**
|
||||
* Controleert of de gebruiker toegang heeft tot het bord
|
||||
*/
|
||||
function check_board_access(PDO $db, int $boardId, int $userId, bool $isAdmin): bool {
|
||||
if ($isAdmin) return true;
|
||||
$stmt = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmt->execute([$boardId, $userId]);
|
||||
return ((int)$stmt->fetchColumn()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleert of de gebruiker beheerder is van het bord
|
||||
*/
|
||||
function check_board_admin(PDO $db, int $boardId, int $userId, bool $isAdmin): bool {
|
||||
if ($isAdmin) return true;
|
||||
$stmt = $db->prepare("SELECT role FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmt->execute([$boardId, $userId]);
|
||||
$role = $stmt->fetchColumn();
|
||||
return ($role === 'ADMIN');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Alle beschikbare borden ophalen voor deze gebruiker
|
||||
if ($action === 'get_boards') {
|
||||
if ($isAdmin) {
|
||||
$stmt = $db->query("
|
||||
SELECT b.*, u.name as creator_name,
|
||||
(SELECT COUNT(*) FROM board_cards WHERE board_id = b.id) as card_count
|
||||
FROM boards b
|
||||
LEFT JOIN users u ON b.created_by_id = u.id
|
||||
WHERE b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$boards = $stmt->fetchAll();
|
||||
} else {
|
||||
$stmt = $db->prepare("
|
||||
SELECT b.*, u.name as creator_name,
|
||||
(SELECT COUNT(*) FROM board_cards WHERE board_id = b.id) as card_count
|
||||
FROM boards b
|
||||
JOIN board_members bm ON b.id = bm.board_id
|
||||
LEFT JOIN users u ON b.created_by_id = u.id
|
||||
WHERE bm.user_id = ? AND b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$boards = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'boards' => $boards,
|
||||
'is_admin' => $isAdmin
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Volledige bord-data ophalen (lanes, kaarten, toewijzingen, autorisaties)
|
||||
if ($action === 'get_board_data') {
|
||||
$boardId = (int)($input['board_id'] ?? ($_GET['board_id'] ?? 0));
|
||||
|
||||
// Als geen board_id is meegegeven, pak het eerste beschikbare bord
|
||||
if ($boardId <= 0) {
|
||||
if ($isAdmin) {
|
||||
$boardId = (int)$db->query("SELECT id FROM boards WHERE is_archived = 0 ORDER BY id ASC LIMIT 1")->fetchColumn();
|
||||
} else {
|
||||
$stmtFirst = $db->prepare("SELECT b.id FROM boards b JOIN board_members bm ON b.id = bm.board_id WHERE bm.user_id = ? AND b.is_archived = 0 ORDER BY b.id ASC LIMIT 1");
|
||||
$stmtFirst->execute([$userId]);
|
||||
$boardId = (int)$stmtFirst->fetchColumn();
|
||||
}
|
||||
}
|
||||
|
||||
if ($boardId <= 0) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'has_board' => false,
|
||||
'message' => 'Geen actieve borden gevonden.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang tot dit bord. Vraag de beheerder om autorisatie.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Haal bord-informatie op
|
||||
$stmtB = $db->prepare("SELECT b.*, u.name as creator_name FROM boards b LEFT JOIN users u ON b.created_by_id = u.id WHERE b.id = ?");
|
||||
$stmtB->execute([$boardId]);
|
||||
$board = $stmtB->fetch();
|
||||
|
||||
// Haal lanes op
|
||||
$stmtL = $db->prepare("SELECT * FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC");
|
||||
$stmtL->execute([$boardId]);
|
||||
$lanes = $stmtL->fetchAll();
|
||||
|
||||
// Haal kaarten op met assignee en schedule
|
||||
$stmtC = $db->prepare("
|
||||
SELECT c.*,
|
||||
u.name as assignee_name, u.email as assignee_email, u.avatar_url as assignee_avatar,
|
||||
s.id as schedule_id, s.schedule_type, s.start_date, s.end_date, s.start_time, s.end_time, s.days_of_week, s.outlook_event_id, s.outlook_synced_at
|
||||
FROM board_cards c
|
||||
LEFT JOIN users u ON c.assignee_id = u.id
|
||||
LEFT JOIN board_card_schedules s ON c.id = s.card_id
|
||||
WHERE c.board_id = ?
|
||||
ORDER BY c.position ASC, c.id DESC
|
||||
");
|
||||
$stmtC->execute([$boardId]);
|
||||
$cards = $stmtC->fetchAll();
|
||||
|
||||
// Groepeer kaarten per lane
|
||||
$cardsByLane = [];
|
||||
foreach ($lanes as $lane) {
|
||||
$cardsByLane[$lane['id']] = [];
|
||||
}
|
||||
foreach ($cards as $card) {
|
||||
$lId = $card['lane_id'];
|
||||
if (!isset($cardsByLane[$lId])) {
|
||||
$cardsByLane[$lId] = [];
|
||||
}
|
||||
$cardsByLane[$lId][] = $card;
|
||||
}
|
||||
|
||||
// Haal teamleden en Outlook integratie status op (voor toewijzing)
|
||||
$usersStmt = $db->query("SELECT id, name, email, role, avatar_url FROM users ORDER BY name ASC");
|
||||
$teamMembers = $usersStmt->fetchAll();
|
||||
|
||||
// Check welke collega's een actieve Outlook koppeling hebben
|
||||
$outlookActiveUsers = [];
|
||||
$intStmt = $db->query("SELECT user_id FROM integrations WHERE provider = 'OUTLOOK' AND is_enabled = 1 AND access_token IS NOT NULL");
|
||||
foreach ($intStmt->fetchAll() as $row) {
|
||||
$outlookActiveUsers[(int)$row['user_id']] = true;
|
||||
}
|
||||
foreach ($teamMembers as &$tm) {
|
||||
$tm['has_outlook'] = !empty($outlookActiveUsers[(int)$tm['id']]);
|
||||
}
|
||||
unset($tm);
|
||||
|
||||
// Haal geautoriseerde leden van dit bord op
|
||||
$membersStmt = $db->prepare("SELECT user_id, role FROM board_members WHERE board_id = ?");
|
||||
$membersStmt->execute([$boardId]);
|
||||
$authorizedMembers = [];
|
||||
foreach ($membersStmt->fetchAll() as $m) {
|
||||
$authorizedMembers[(int)$m['user_id']] = $m['role'];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'has_board' => true,
|
||||
'board' => $board,
|
||||
'lanes' => $lanes,
|
||||
'cards_by_lane' => $cardsByLane,
|
||||
'team_members' => $teamMembers,
|
||||
'authorized_members' => $authorizedMembers,
|
||||
'is_board_admin' => check_board_admin($db, $boardId, $userId, $isAdmin),
|
||||
'current_user_id' => $userId,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Nieuw bord aanmaken (Beheerder)
|
||||
if ($action === 'create_board') {
|
||||
if (!$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Alleen beheerders kunnen nieuwe borden aanmaken.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$title = trim($input['title'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
|
||||
if (empty($title)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Vul een bordtitel in.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO boards (title, description, created_by_id) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$title, $description, $userId]);
|
||||
$boardId = (int)$db->lastInsertId();
|
||||
|
||||
// Voeg maker toe als beheerder van het bord
|
||||
$db->prepare("INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'ADMIN')")
|
||||
->execute([$boardId, $userId]);
|
||||
|
||||
// Standaard lanes aanmaken
|
||||
$defaultLanes = [
|
||||
['title' => 'Open', 'color' => 'slate', 'pos' => 0],
|
||||
['title' => 'In uitvoering', 'color' => 'amber', 'pos' => 1],
|
||||
['title' => 'Testen', 'color' => 'purple', 'pos' => 2],
|
||||
['title' => 'Klaar', 'color' => 'emerald', 'pos' => 3],
|
||||
];
|
||||
$laneStmt = $db->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)");
|
||||
foreach ($defaultLanes as $l) {
|
||||
$laneStmt->execute([$boardId, $l['title'], $l['color'], $l['pos']]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'board_id' => $boardId,
|
||||
'message' => 'Bord succesvol aangemaakt!'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. Bord autorisaties / leden beheren (Beheerder)
|
||||
if ($action === 'manage_members') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
if (!check_board_admin($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen beheerrechten voor dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$memberIds = $input['member_ids'] ?? [];
|
||||
if (!is_array($memberIds)) $memberIds = [];
|
||||
|
||||
// Huidige leden bijwerken
|
||||
$db->prepare("DELETE FROM board_members WHERE board_id = ?")->execute([$boardId]);
|
||||
$insertStmt = $db->prepare("INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'MEMBER')");
|
||||
foreach ($memberIds as $mId) {
|
||||
$mId = (int)$mId;
|
||||
if ($mId > 0) {
|
||||
$insertStmt->execute([$boardId, $mId]);
|
||||
}
|
||||
}
|
||||
// Zorg dat beheerder altijd lid blijft
|
||||
$db->prepare("INSERT IGNORE INTO board_members (board_id, user_id, role) VALUES (?, ?, 'ADMIN')")->execute([$boardId, $userId]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Toegangsrechten voor het bord succesvol bijgewerkt.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Kolom / Lane toevoegen of bewerken
|
||||
if ($action === 'save_lane') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$title = trim($input['title'] ?? '');
|
||||
$color = trim($input['color'] ?? 'slate');
|
||||
|
||||
if (!check_board_admin($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Alleen bordbeheerders kunnen kolommen beheren.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($title)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Vul een kolomnaam in.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($laneId > 0) {
|
||||
$stmt = $db->prepare("UPDATE board_lanes SET title = ?, color = ? WHERE id = ? AND board_id = ?");
|
||||
$stmt->execute([$title, $color, $laneId, $boardId]);
|
||||
} else {
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_lanes WHERE board_id = ?");
|
||||
$stmtMax->execute([$boardId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
$stmt = $db->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$boardId, $title, $color, $maxPos + 1]);
|
||||
$laneId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'lane_id' => $laneId, 'message' => 'Kolom opgeslagen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 6. Kolom / Lane verwijderen
|
||||
if ($action === 'delete_lane') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
|
||||
if (!check_board_admin($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen beheerrechten.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Controleer of er kaarten in staan
|
||||
$stmtCardCnt = $db->prepare("SELECT COUNT(*) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtCardCnt->execute([$laneId]);
|
||||
$cardCount = (int)$stmtCardCnt->fetchColumn();
|
||||
if ($cardCount > 0) {
|
||||
echo json_encode(['success' => false, 'error' => 'Verplaats of verwijder eerst de taken in deze kolom alvorens de kolom te verwijderen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$db->prepare("DELETE FROM board_lanes WHERE id = ? AND board_id = ?")->execute([$laneId, $boardId]);
|
||||
echo json_encode(['success' => true, 'message' => 'Kolom verwijderd.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 7. Kaart (Post-it / Opdracht) aanmaken of bewerken
|
||||
if ($action === 'save_card') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$cardId = (int)($input['card_id'] ?? 0);
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$title = trim($input['title'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
$assigneeId = !empty($input['assignee_id']) ? (int)$input['assignee_id'] : null;
|
||||
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
|
||||
$color = in_array($input['color'] ?? '', ['yellow', 'blue', 'green', 'purple', 'pink', 'orange', 'slate']) ? $input['color'] : 'yellow';
|
||||
|
||||
if (!check_board_access($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang tot dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($title)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Vul een titel in voor de taak.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($cardId > 0) {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE board_cards
|
||||
SET title = ?, description = ?, assignee_id = ?, priority = ?, color = ?, lane_id = ?
|
||||
WHERE id = ? AND board_id = ?
|
||||
");
|
||||
$stmt->execute([$title, $description, $assigneeId, $priority, $color, $laneId, $cardId, $boardId]);
|
||||
} else {
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([$boardId, $laneId, $title, $description, $assigneeId, $priority, $color, $maxPos + 1, $userId]);
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
// Tijdspad opslaan en optioneel Outlook agenda synchroniseren indien meegegeven
|
||||
$scheduleData = $input['schedule'] ?? null;
|
||||
$outlookSyncMsg = null;
|
||||
if (!empty($scheduleData) && is_array($scheduleData) && !empty($scheduleData['enabled'])) {
|
||||
$scheduleType = in_array($scheduleData['type'] ?? '', ['SINGLE', 'DAILY_RANGE']) ? $scheduleData['type'] : 'SINGLE';
|
||||
$startDate = trim($scheduleData['start_date'] ?? date('Y-m-d'));
|
||||
$endDate = ($scheduleType === 'DAILY_RANGE' && !empty($scheduleData['end_date'])) ? trim($scheduleData['end_date']) : $startDate;
|
||||
$startTime = trim($scheduleData['start_time'] ?? '14:00');
|
||||
$endTime = trim($scheduleData['end_time'] ?? '15:00');
|
||||
$daysOfWeek = trim($scheduleData['days_of_week'] ?? '1,2,3,4,5');
|
||||
$syncOutlook = !empty($scheduleData['sync_outlook']);
|
||||
|
||||
$outlookEventId = null;
|
||||
$outlookSyncedAt = null;
|
||||
|
||||
// Als Outlook sync gevraagd is én er een assignee is:
|
||||
if ($syncOutlook && $assigneeId > 0) {
|
||||
// Controleer of de assignee Outlook gekoppeld heeft
|
||||
$stmtCheckInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
|
||||
$stmtCheckInt->execute([$assigneeId]);
|
||||
$outlookInt = $stmtCheckInt->fetch();
|
||||
|
||||
if (!empty($outlookInt['access_token'])) {
|
||||
$startTs = strtotime("{$startDate} {$startTime}");
|
||||
$endTs = strtotime("{$startDate} {$endTime}");
|
||||
$durationMins = max(15, (int)(($endTs - $startTs) / 60));
|
||||
|
||||
// Maak agenda-afspraak aan in Outlook
|
||||
$eventPayload = [
|
||||
'subject' => "[Fociz Bord] {$title}",
|
||||
'description' => "Opdracht toegewezen via Fociz Bord:\n\n{$description}\n\nTijdspad: {$startTime} - {$endTime}",
|
||||
'date' => $startDate,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
'duration_minutes' => $durationMins,
|
||||
];
|
||||
|
||||
$resCreate = microsoft_create_event($assigneeId, $eventPayload);
|
||||
if (!empty($resCreate['success'])) {
|
||||
$outlookEventId = $resCreate['event_id'] ?? ($resCreate['id'] ?? null);
|
||||
$outlookSyncedAt = date('Y-m-d H:i:s');
|
||||
$outlookSyncMsg = 'Taak succesvol ingepland in de Outlook agenda van de collega!';
|
||||
} else {
|
||||
$outlookSyncMsg = 'Taak opgeslagen, maar Outlook-sync mislukt: ' . ($resCreate['error'] ?? 'onbekende fout');
|
||||
}
|
||||
} else {
|
||||
$outlookSyncMsg = 'Let op: De toegewezen collega heeft Microsoft Outlook 365 nog niet gekoppeld.';
|
||||
}
|
||||
}
|
||||
|
||||
// Opslaan in board_card_schedules
|
||||
$stmtSched = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week, outlook_event_id, outlook_synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
schedule_type = VALUES(schedule_type),
|
||||
start_date = VALUES(start_date),
|
||||
end_date = VALUES(end_date),
|
||||
start_time = VALUES(start_time),
|
||||
end_time = VALUES(end_time),
|
||||
days_of_week = VALUES(days_of_week),
|
||||
outlook_event_id = COALESCE(VALUES(outlook_event_id), outlook_event_id),
|
||||
outlook_synced_at = COALESCE(VALUES(outlook_synced_at), outlook_synced_at)
|
||||
");
|
||||
$stmtSched->execute([$cardId, $scheduleType, $startDate, $endDate, $startTime, $endTime, $daysOfWeek, $outlookEventId, $outlookSyncedAt]);
|
||||
} elseif (isset($scheduleData['enabled']) && !$scheduleData['enabled']) {
|
||||
// Tijdspad uitschakelen indien expliciet uitgezet
|
||||
$db->prepare("DELETE FROM board_card_schedules WHERE card_id = ?")->execute([$cardId]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'outlook_message' => $outlookSyncMsg,
|
||||
'message' => 'Taak succesvol opgeslagen!'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 8. Kaart verplaatsen naar een andere lane of positie (Drag & Drop / Quick move)
|
||||
if ($action === 'move_card') {
|
||||
$cardId = (int)($input['card_id'] ?? 0);
|
||||
$targetLaneId = (int)($input['target_lane_id'] ?? 0);
|
||||
$targetPosition = (int)($input['target_position'] ?? 0);
|
||||
|
||||
$stmtCard = $db->prepare("SELECT * FROM board_cards WHERE id = ?");
|
||||
$stmtCard->execute([$cardId]);
|
||||
$card = $stmtCard->fetch();
|
||||
if (!$card) {
|
||||
echo json_encode(['success' => false, 'error' => 'Taak niet gevonden.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, (int)$card['board_id'], $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Valideer of de doellane daadwerkelijk bij hetzelfde bord hoort (voorkom IDOR/lane hijacking)
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE id = ? AND board_id = ?");
|
||||
$stmtLane->execute([$targetLaneId, (int)$card['board_id']]);
|
||||
if (!$stmtLane->fetch()) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldige doellane voor dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE board_cards SET lane_id = ?, position = ? WHERE id = ?");
|
||||
$stmt->execute([$targetLaneId, $targetPosition, $cardId]);
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 9. Kaart verwijderen
|
||||
if ($action === 'delete_card') {
|
||||
$cardId = (int)($input['card_id'] ?? 0);
|
||||
|
||||
$stmtCard = $db->prepare("
|
||||
SELECT c.*, s.outlook_event_id
|
||||
FROM board_cards c
|
||||
LEFT JOIN board_card_schedules s ON c.id = s.card_id
|
||||
WHERE c.id = ?
|
||||
");
|
||||
$stmtCard->execute([$cardId]);
|
||||
$card = $stmtCard->fetch();
|
||||
|
||||
if (!$card) {
|
||||
echo json_encode(['success' => false, 'error' => 'Taak niet gevonden.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, (int)$card['board_id'], $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen rechten.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Indien gekoppeld aan Outlook van de assignee, verwijder ook daar de afspraak
|
||||
if (!empty($card['outlook_event_id']) && !empty($card['assignee_id'])) {
|
||||
try {
|
||||
microsoft_delete_event((int)$card['assignee_id'], $card['outlook_event_id']);
|
||||
} catch (Throwable $t) {}
|
||||
}
|
||||
|
||||
$db->prepare("DELETE FROM board_cards WHERE id = ?")->execute([$cardId]);
|
||||
echo json_encode(['success' => true, 'message' => 'Taak verwijderd.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 10. Tijdlijn Datums Snel Bijwerken (Startdatum & Einddatum via Gantt drag)
|
||||
if ($action === 'update_card_dates') {
|
||||
$cardId = (int)($input['card_id'] ?? 0);
|
||||
$startDate = trim($input['start_date'] ?? '');
|
||||
$endDate = trim($input['end_date'] ?? $startDate);
|
||||
|
||||
if (!$cardId || !$startDate) {
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldige parameters.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtCard = $db->prepare("SELECT * FROM board_cards WHERE id = ?");
|
||||
$stmtCard->execute([$cardId]);
|
||||
$card = $stmtCard->fetch();
|
||||
if (!$card) {
|
||||
echo json_encode(['success' => false, 'error' => 'Taak niet gevonden.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, (int)$card['board_id'], $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang tot dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtSched = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
|
||||
VALUES (?, 'DAILY_RANGE', ?, ?, '09:00', '17:00', '1,2,3,4,5')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
start_date = VALUES(start_date),
|
||||
end_date = VALUES(end_date),
|
||||
schedule_type = 'DAILY_RANGE'
|
||||
");
|
||||
$stmtSched->execute([$cardId, $startDate, $endDate]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'message' => 'Tijdlijn bijgewerkt!'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 11. Snelle Inline Taak Aanmaken (⚡ Wat moet er gedaan worden?)
|
||||
if ($action === 'quick_create_card') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$title = trim($input['title'] ?? '');
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$startDate = !empty($input['start_date']) ? trim($input['start_date']) : null;
|
||||
$endDate = !empty($input['end_date']) ? trim($input['end_date']) : $startDate;
|
||||
|
||||
if (!$boardId || empty($title)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Vul een titel in voor de taak.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang tot dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Als geen lane_id is meegegeven, pak de 1e lane van het bord
|
||||
if ($laneId <= 0) {
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
|
||||
$stmtLane->execute([$boardId]);
|
||||
$laneId = (int)$stmtLane->fetchColumn();
|
||||
}
|
||||
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, '', NULL, 'P2', 'yellow', ?, ?)
|
||||
");
|
||||
$stmt->execute([$boardId, $laneId, $title, $maxPos + 1, $userId]);
|
||||
$newCardId = (int)$db->lastInsertId();
|
||||
|
||||
// Optioneel datums toekennen
|
||||
if ($startDate) {
|
||||
$stmtSched = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
|
||||
VALUES (?, 'DAILY_RANGE', ?, ?, '09:00', '17:00', '1,2,3,4,5')
|
||||
");
|
||||
$stmtSched->execute([$newCardId, $startDate, $endDate]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $newCardId,
|
||||
'lane_id' => $laneId,
|
||||
'message' => 'Taak direct toegevoegd!'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => false, 'error' => 'Onbekende actie.']);
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fociz Board API Fout: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Serverfout: ' . $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -47,11 +47,25 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Stemmen / Upvote op kaart
|
||||
// 2. Stemmen / Upvote op kaart (met bescherming tegen oneindig stemmen)
|
||||
if ($action === 'vote_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
|
||||
if (!isset($_SESSION['voted_cards'])) {
|
||||
$_SESSION['voted_cards'] = [];
|
||||
}
|
||||
|
||||
if (in_array($cardId, $_SESSION['voted_cards'])) {
|
||||
$stmtVotes = $db->prepare("SELECT votes FROM brainstorm_cards WHERE id = ?");
|
||||
$stmtVotes->execute([$cardId]);
|
||||
$votes = (int)$stmtVotes->fetchColumn();
|
||||
echo json_encode(['success' => false, 'error' => 'U heeft al op deze kaart gestemd.', 'votes' => $votes]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$_SESSION['voted_cards'][] = $cardId;
|
||||
|
||||
$stmt = $db->prepare("UPDATE brainstorm_cards SET votes = votes + 1 WHERE id = ?");
|
||||
$stmt->execute([$cardId]);
|
||||
|
||||
@@ -63,11 +77,35 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Kaart verwijderen
|
||||
// 3. Kaart verwijderen (met autorisatiecontrole: auteur, sessie-eigenaar of beheerder)
|
||||
if ($action === 'delete_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
|
||||
$stmtCheck = $db->prepare("
|
||||
SELECT c.*, s.created_by_id as session_owner_id
|
||||
FROM brainstorm_cards c
|
||||
JOIN brainstorm_sessions s ON c.session_id = s.id
|
||||
WHERE c.id = ?
|
||||
");
|
||||
$stmtCheck->execute([$cardId]);
|
||||
$card = $stmtCheck->fetch();
|
||||
|
||||
if (!$card) {
|
||||
echo json_encode(['error' => 'Kaart niet gevonden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$isAuthor = ($card['author_name'] === $user['name']);
|
||||
$isSessionOwner = ((int)$card['session_owner_id'] === (int)$user['id']);
|
||||
$isAdmin = ($user['role'] === 'ADMIN');
|
||||
|
||||
if (!$isAuthor && !$isSessionOwner && !$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Geen toestemming om deze kaart te verwijderen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("DELETE FROM brainstorm_cards WHERE id = ?");
|
||||
$stmt->execute([$cardId]);
|
||||
|
||||
@@ -75,7 +113,200 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. Omzetten naar Roadmap!
|
||||
// 3b. Kaart verplaatsen naar andere categorie/lane (Drag & Drop)
|
||||
if ($action === 'move_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
$category = trim($data['category'] ?? '');
|
||||
|
||||
$validCategories = ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins'];
|
||||
if (!$cardId || !in_array($category, $validCategories, true)) {
|
||||
echo json_encode(['error' => 'Ongeldige kaart of categorie']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Bepaal kleur behorend bij de nieuwe categorie
|
||||
$colorMap = [
|
||||
'Ideeën' => 'amber',
|
||||
'Knelpunten' => 'rose',
|
||||
'Oplossingen' => 'teal',
|
||||
'Quick Wins' => 'indigo'
|
||||
];
|
||||
$newColor = $colorMap[$category] ?? 'amber';
|
||||
|
||||
$stmt = $db->prepare("UPDATE brainstorm_cards SET category = ?, color = ? WHERE id = ?");
|
||||
$stmt->execute([$category, $newColor, $cardId]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'category' => $category,
|
||||
'color' => $newColor
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3c. Kaart bewerken (Inhoud, Categorie en/of Kleur)
|
||||
if ($action === 'update_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
$content = trim($data['content'] ?? '');
|
||||
$category = trim($data['category'] ?? '');
|
||||
|
||||
if (!$cardId || empty($content)) {
|
||||
echo json_encode(['error' => 'Kaart ID en inhoud zijn verplicht']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$colorMap = [
|
||||
'Ideeën' => 'amber',
|
||||
'Knelpunten' => 'rose',
|
||||
'Oplossingen' => 'teal',
|
||||
'Quick Wins' => 'indigo'
|
||||
];
|
||||
$color = $colorMap[$category] ?? 'amber';
|
||||
|
||||
if (!empty($category)) {
|
||||
$stmt = $db->prepare("UPDATE brainstorm_cards SET content = ?, category = ?, color = ? WHERE id = ?");
|
||||
$stmt->execute([$content, $category, $color, $cardId]);
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE brainstorm_cards SET content = ? WHERE id = ?");
|
||||
$stmt->execute([$content, $cardId]);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'card_id' => $cardId, 'content' => $content, 'category' => $category, 'color' => $color]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3d. Kaart toewijzen aan collega en inplannen in agenda (Vrij blok)
|
||||
if ($action === 'assign_and_schedule') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
$assigneeId = !empty($data['assignee_id']) ? (int)$data['assignee_id'] : null;
|
||||
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : null;
|
||||
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : null;
|
||||
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : null;
|
||||
$durationMinutes = (int)($data['duration_minutes'] ?? 30);
|
||||
$syncOutlook = !empty($data['sync_outlook']);
|
||||
|
||||
if (!$cardId) {
|
||||
echo json_encode(['error' => 'Kaart ID ontbreekt']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtCard = $db->prepare("SELECT * FROM brainstorm_cards WHERE id = ?");
|
||||
$stmtCard->execute([$cardId]);
|
||||
$card = $stmtCard->fetch();
|
||||
if (!$card) {
|
||||
echo json_encode(['error' => 'Kaart niet gevonden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Bereken eindtijd indien nodig
|
||||
if ($startTime && !$endTime && $durationMinutes > 0) {
|
||||
$tParts = explode(':', $startTime);
|
||||
$startMins = ((int)$tParts[0] * 60) + (int)($tParts[1] ?? 0);
|
||||
$endMins = $startMins + $durationMinutes;
|
||||
$endTime = sprintf('%02d:%02d', floor($endMins / 60), $endMins % 60);
|
||||
}
|
||||
|
||||
$createdTaskId = null;
|
||||
$outlookSyncMsg = null;
|
||||
|
||||
// Als er een datum & tijd is gekozen én een collega (of huidige gebruiker)
|
||||
if ($assigneeId && $planDate && $startTime) {
|
||||
// Zoek of maak daily plan
|
||||
$stmtPlan = $db->prepare("
|
||||
INSERT INTO daily_plans (user_id, plan_date)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
|
||||
");
|
||||
$stmtPlan->execute([$assigneeId, $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
|
||||
$taskTitle = "Brainstorm taak: " . mb_substr($card['content'], 0, 75);
|
||||
$taskDesc = "Afkomstig van Brainstormsessie. Inhoud:\n" . $card['content'];
|
||||
$source = 'BRAINSTORM';
|
||||
$externalId = null;
|
||||
$externalUrl = null;
|
||||
|
||||
// Indien Outlook sync gewenst is
|
||||
if ($syncOutlook) {
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
try {
|
||||
$msResult = microsoft_create_event($assigneeId, [
|
||||
'subject' => $taskTitle,
|
||||
'description' => $taskDesc,
|
||||
'date' => $planDate,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
'duration_minutes' => $durationMinutes,
|
||||
]);
|
||||
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||
$source = 'OUTLOOK';
|
||||
$externalId = $msResult['event_id'];
|
||||
$externalUrl = $msResult['web_link'] ?? null;
|
||||
$outlookSyncMsg = 'Ingepland in Outlook 365 agenda.';
|
||||
}
|
||||
} catch (Throwable $t) {
|
||||
// stil doorgaan
|
||||
}
|
||||
}
|
||||
|
||||
// Maak of update taak
|
||||
if (!empty($card['scheduled_task_id'])) {
|
||||
$stmtUpdateTask = $db->prepare("
|
||||
UPDATE tasks
|
||||
SET user_id = ?, daily_plan_id = ?, title = ?, description = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmtUpdateTask->execute([$assigneeId, $planId, $taskTitle, $taskDesc, $startTime, $endTime, $durationMinutes, $card['scheduled_task_id']]);
|
||||
$createdTaskId = (int)$card['scheduled_task_id'];
|
||||
} else {
|
||||
$stmtInsertTask = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, description, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
|
||||
VALUES (?, ?, ?, ?, 'P2', ?, ?, ?, 'TODO', ?, ?, ?)
|
||||
");
|
||||
$stmtInsertTask->execute([$assigneeId, $planId, $taskTitle, $taskDesc, $startTime, $endTime, $durationMinutes, $source, $externalId, $externalUrl]);
|
||||
$createdTaskId = (int)$db->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
// Update brainstorm kaart
|
||||
$stmtUpdateCard = $db->prepare("
|
||||
UPDATE brainstorm_cards
|
||||
SET assignee_id = ?, scheduled_date = ?, scheduled_start_time = ?, scheduled_end_time = ?, scheduled_task_id = ?
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmtUpdateCard->execute([$assigneeId, $planDate, $startTime, $endTime, $createdTaskId, $cardId]);
|
||||
|
||||
// Haal assignee naam & avatar op
|
||||
$assigneeName = null;
|
||||
$assigneeAvatar = null;
|
||||
if ($assigneeId) {
|
||||
$stmtUser = $db->prepare("SELECT name, avatar_url FROM users WHERE id = ?");
|
||||
$stmtUser->execute([$assigneeId]);
|
||||
$usr = $stmtUser->fetch();
|
||||
if ($usr) {
|
||||
$assigneeName = $usr['name'];
|
||||
$assigneeAvatar = $usr['avatar_url'];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'assignee_id' => $assigneeId,
|
||||
'assignee_name' => $assigneeName,
|
||||
'assignee_avatar' => $assigneeAvatar,
|
||||
'scheduled_date' => $planDate,
|
||||
'scheduled_start_time' => $startTime,
|
||||
'scheduled_end_time' => $endTime,
|
||||
'scheduled_task_id' => $createdTaskId,
|
||||
'outlook_message' => $outlookSyncMsg
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
if ($action === 'convert_to_roadmap') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardIds = $data['card_ids'] ?? [];
|
||||
@@ -137,6 +368,53 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Brainstormsessie verwijderen (inclusief bijbehorende kaarten)
|
||||
if ($action === 'delete_session') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
|
||||
if (!$sessionId) {
|
||||
echo json_encode(['success' => false, 'error' => 'Geen geldig sessie ID opgegeven.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtCheck = $db->prepare("SELECT id, title, created_by_id FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtCheck->execute([$sessionId]);
|
||||
$session = $stmtCheck->fetch();
|
||||
|
||||
if (!$session) {
|
||||
echo json_encode(['success' => false, 'error' => 'Brainstormsessie niet gevonden.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$isCreator = ((int)$session['created_by_id'] === (int)$user['id']);
|
||||
$isAdmin = ($user['role'] === 'ADMIN');
|
||||
|
||||
if (!$isCreator && !$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Alleen de maker van deze sessie of een beheerder kan deze verwijderen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
// Verwijder kaarten behorend bij deze sessie
|
||||
$stmtDelCards = $db->prepare("DELETE FROM brainstorm_cards WHERE session_id = ?");
|
||||
$stmtDelCards->execute([$sessionId]);
|
||||
|
||||
// Verwijder de sessie zelf
|
||||
$stmtDelSession = $db->prepare("DELETE FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtDelSession->execute([$sessionId]);
|
||||
|
||||
$db->commit();
|
||||
echo json_encode(['success' => true, 'message' => 'Brainstormsessie en alle bijbehorende kaarten zijn definitief verwijderd.']);
|
||||
exit;
|
||||
} catch (Throwable $t) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
throw $t;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende actie']);
|
||||
} catch (Exception $e) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
|
||||
@@ -28,7 +28,7 @@ try {
|
||||
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
|
||||
$stmt->execute([$user['id']]);
|
||||
$saved = json_decode($stmt->fetchColumn() ?: '{}', true);
|
||||
$apiToken = $saved['jira_api_token'] ?? '';
|
||||
$apiToken = fociz_decrypt($saved['jira_api_token'] ?? '');
|
||||
if (empty($domain)) $domain = $saved['jira_domain'] ?? '';
|
||||
if (empty($email)) $email = $saved['jira_email'] ?? '';
|
||||
}
|
||||
@@ -40,7 +40,7 @@ try {
|
||||
$settings = json_encode([
|
||||
'jira_domain' => jira_normalize_domain($domain),
|
||||
'jira_email' => $email,
|
||||
'jira_api_token' => $apiToken,
|
||||
'jira_api_token' => fociz_encrypt($apiToken),
|
||||
'account_name' => $jiraUser['displayName'] ?? '',
|
||||
'account_id' => $jiraUser['accountId'] ?? '',
|
||||
]);
|
||||
@@ -58,6 +58,7 @@ try {
|
||||
|
||||
// Wis eventuele oude sessie cache
|
||||
unset($_SESSION["jira_issues_user_{$user['id']}"]);
|
||||
unset($_SESSION["jira_dashboard_user_{$user['id']}"]);
|
||||
}
|
||||
|
||||
echo json_encode($res);
|
||||
@@ -72,6 +73,214 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2b. Jira Dashboard Data Ophalen (Toegewezen + Team issues + Stats)
|
||||
if ($action === 'get_jira_dashboard') {
|
||||
$force = !empty($_GET['force']) || !empty($_POST['force']);
|
||||
$res = jira_get_dashboard_data($user['id'], $force);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2c. Jira Issue inplannen in Dagplanner
|
||||
if ($action === 'plan_jira_task') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$key = trim($input['key'] ?? '');
|
||||
$summary = trim($input['summary'] ?? '');
|
||||
$url = trim($input['url'] ?? '');
|
||||
$planDate = !empty($input['plan_date']) ? trim($input['plan_date']) : date('Y-m-d');
|
||||
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
|
||||
$timeBlockStart = !empty($input['time_block_start']) ? trim($input['time_block_start']) : null;
|
||||
$timeBlockEnd = !empty($input['time_block_end']) ? trim($input['time_block_end']) : null;
|
||||
$durationMinutes = !empty($input['duration_minutes']) ? (int)$input['duration_minutes'] : 45;
|
||||
|
||||
if (empty($key) || empty($summary)) {
|
||||
echo json_encode(['error' => 'Ticket key en titel zijn verplicht']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$customTitle = trim($input['title'] ?? '');
|
||||
$title = !empty($customTitle) ? $customTitle : "[{$key}] {$summary}";
|
||||
|
||||
// Zoek of maak daily plan aan voor deze datum
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $planDate]);
|
||||
$planId = $stmtPlan->fetchColumn();
|
||||
|
||||
if (!$planId) {
|
||||
$stmtCreatePlan = $db->prepare("INSERT INTO daily_plans (user_id, plan_date) VALUES (?, ?)");
|
||||
$stmtCreatePlan->execute([$user['id'], $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
// Bereken eindtijd indien starttijd bekend is
|
||||
if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
|
||||
try {
|
||||
$dtEnd = new DateTime("{$planDate} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||
$dtEnd->modify("+{$durationMinutes} minutes");
|
||||
$timeBlockEnd = $dtEnd->format('H:i');
|
||||
} catch (Throwable $e) {}
|
||||
}
|
||||
|
||||
$source = 'JIRA';
|
||||
$externalId = $key;
|
||||
$externalUrl = $url;
|
||||
$outlookSyncMsg = null;
|
||||
|
||||
// Automatisch synchroniseren naar Microsoft Outlook indien gekoppeld
|
||||
if (function_exists('microsoft_create_event')) {
|
||||
try {
|
||||
$syncStartTime = !empty($timeBlockStart) ? $timeBlockStart : '09:00';
|
||||
$msResult = microsoft_create_event($user['id'], [
|
||||
'subject' => $title,
|
||||
'description' => "Jira ticket {$key} gepland via Fociz ({$priority})\n\n{$url}",
|
||||
'date' => $planDate,
|
||||
'start_time' => $syncStartTime,
|
||||
'end_time' => $timeBlockEnd,
|
||||
'duration_minutes' => $durationMinutes,
|
||||
]);
|
||||
|
||||
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||
$source = 'OUTLOOK';
|
||||
$externalId = $msResult['event_id'];
|
||||
$externalUrl = $msResult['web_link'] ?? ($url ?: 'https://outlook.office.com');
|
||||
$timeBlockStart = $msResult['start_time'] ?? $syncStartTime;
|
||||
$timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd;
|
||||
$outlookSyncMsg = 'Gesynchroniseerd naar Microsoft Outlook.';
|
||||
}
|
||||
} catch (Throwable $t) {
|
||||
error_log("Fout bij auto-sync Jira naar Outlook: " . $t->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Voeg taak toe
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'TODO', ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$user['id'],
|
||||
$planId,
|
||||
$title,
|
||||
$priority,
|
||||
$timeBlockStart,
|
||||
$timeBlockEnd,
|
||||
$durationMinutes,
|
||||
$source,
|
||||
$externalId,
|
||||
$externalUrl
|
||||
]);
|
||||
$taskId = (int)$db->lastInsertId();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'outlook_synced' => ($source === 'OUTLOOK'),
|
||||
'message' => "Ticket {$key} is succesvol ingepland voor " . date('d-m-Y', strtotime($planDate)) . "!" . ($outlookSyncMsg ? " ({$outlookSyncMsg})" : ""),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2d. Jira Issue toevoegen aan een Kanban Bord kolom
|
||||
if ($action === 'board_jira_card') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$key = trim($input['key'] ?? '');
|
||||
$summary = trim($input['summary'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
$url = trim($input['url'] ?? '');
|
||||
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
|
||||
$color = in_array($input['color'] ?? '', ['yellow', 'blue', 'green', 'purple', 'red', 'pink', 'indigo', 'orange']) ? $input['color'] : 'blue';
|
||||
|
||||
if (empty($boardId) || empty($laneId) || empty($key) || empty($summary)) {
|
||||
echo json_encode(['error' => 'Bord, kolom, ticket key en titel zijn verplicht']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Controleer toegang tot bord
|
||||
$isAdmin = ($user['role'] ?? '') === 'ADMIN';
|
||||
if (!$isAdmin) {
|
||||
$stmtCheck = $db->prepare("SELECT role FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmtCheck->execute([$boardId, $user['id']]);
|
||||
if (!$stmtCheck->fetchColumn()) {
|
||||
echo json_encode(['error' => 'Geen toegang tot dit bord']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Controleer of lane bij bord hoort
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE id = ? AND board_id = ?");
|
||||
$stmtLane->execute([$laneId, $boardId]);
|
||||
if (!$stmtLane->fetchColumn()) {
|
||||
echo json_encode(['error' => 'Kolom hoort niet bij dit bord']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$title = "[{$key}] {$summary}";
|
||||
$descFull = trim(($url ? "🔗 Jira: {$url}\n\n" : "") . $description);
|
||||
|
||||
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = {$laneId}")->fetchColumn();
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$boardId,
|
||||
$laneId,
|
||||
$title,
|
||||
$descFull,
|
||||
$user['id'],
|
||||
$priority,
|
||||
$color,
|
||||
$maxPos + 1,
|
||||
$user['id']
|
||||
]);
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'message' => "Ticket {$key} is succesvol als kaart op het bord geplaatst!",
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2e. Beschikbare borden en kolommen ophalen voor selectiedialoog
|
||||
if ($action === 'get_boards_and_lanes') {
|
||||
$isAdmin = ($user['role'] ?? '') === 'ADMIN';
|
||||
if ($isAdmin) {
|
||||
$boards = $db->query("SELECT id, title FROM boards WHERE is_archived = 0 ORDER BY id ASC")->fetchAll();
|
||||
} else {
|
||||
$stmt = $db->prepare("
|
||||
SELECT b.id, b.title
|
||||
FROM boards b
|
||||
JOIN board_members bm ON b.id = bm.board_id
|
||||
WHERE bm.user_id = ? AND b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$stmt->execute([$user['id']]);
|
||||
$boards = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
$boardIds = array_column($boards, 'id');
|
||||
$lanesByBoard = [];
|
||||
if (!empty($boardIds)) {
|
||||
$inClause = implode(',', array_map('intval', $boardIds));
|
||||
$lanes = $db->query("SELECT id, board_id, title, color FROM board_lanes WHERE board_id IN ({$inClause}) ORDER BY position ASC")->fetchAll();
|
||||
foreach ($lanes as $lane) {
|
||||
$lanesByBoard[$lane['board_id']][] = $lane;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($boards as &$b) {
|
||||
$b['lanes'] = $lanesByBoard[$b['id']] ?? [];
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'boards' => $boards]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Outlook Agenda Afspraken Ophalen
|
||||
if ($action === 'get_outlook_events') {
|
||||
$date = $_GET['date'] ?? date('Y-m-d');
|
||||
@@ -106,6 +315,107 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5a. Jira Wachtrijfilters Ophalen
|
||||
if ($action === 'get_jira_queue_filters') {
|
||||
$onlyActive = !isset($_GET['all']) && !isset($_POST['all']);
|
||||
$filters = jira_get_queue_filters($onlyActive);
|
||||
echo json_encode(['success' => true, 'filters' => $filters]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5b. Jira Wachtrijfilter Opslaan / Wijzigen (Alleen Beheerders)
|
||||
if ($action === 'save_jira_queue_filter') {
|
||||
if (($user['role'] ?? '') !== 'ADMIN') {
|
||||
echo json_encode(['error' => 'Alleen beheerders mogen wachtrijfilters aanpassen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$filterCode = strtoupper(trim($input['filter_code'] ?? ''));
|
||||
$filterName = trim($input['filter_name'] ?? '');
|
||||
$projectKey = strtoupper(trim($input['project_key'] ?? ''));
|
||||
$jqlClause = trim($input['jql_clause'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
$isActive = isset($input['is_active']) ? (int)$input['is_active'] : 1;
|
||||
$sortOrder = (int)($input['sort_order'] ?? 0);
|
||||
|
||||
if (empty($filterCode) || empty($filterName) || empty($jqlClause)) {
|
||||
echo json_encode(['error' => 'Code, naam en JQL-clausule zijn verplicht.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE jira_queue_filters
|
||||
SET filter_code = ?, filter_name = ?, project_key = ?, jql_clause = ?, description = ?, is_active = ?, sort_order = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$filterCode, $filterName, $projectKey ?: null, $jqlClause, $description, $isActive, $sortOrder, $id]);
|
||||
} else {
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO jira_queue_filters (filter_code, filter_name, project_key, jql_clause, description, is_active, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
filter_name = VALUES(filter_name),
|
||||
project_key = VALUES(project_key),
|
||||
jql_clause = VALUES(jql_clause),
|
||||
description = VALUES(description),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order),
|
||||
updated_at = NOW()
|
||||
");
|
||||
$stmt->execute([$filterCode, $filterName, $projectKey ?: null, $jqlClause, $description, $isActive, $sortOrder]);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => "Wachtrijfilter '{$filterName}' is succesvol opgeslagen."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5c. Jira Wachtrijfilter Verwijderen (Alleen Beheerders)
|
||||
if ($action === 'delete_jira_queue_filter') {
|
||||
if (($user['role'] ?? '') !== 'ADMIN') {
|
||||
echo json_encode(['error' => 'Alleen beheerders mogen wachtrijfilters verwijderen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$filterCode = trim($input['filter_code'] ?? '');
|
||||
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare("DELETE FROM jira_queue_filters WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
} elseif (!empty($filterCode)) {
|
||||
$stmt = $db->prepare("DELETE FROM jira_queue_filters WHERE filter_code = ?");
|
||||
$stmt->execute([$filterCode]);
|
||||
} else {
|
||||
echo json_encode(['error' => 'Filter ID of code ontbreekt.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Wachtrijfilter is verwijderd.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5d. Standaard Wachtrijfilter Toewijzen aan Gebruiker (Beheerders of Gebruiker zelf)
|
||||
if ($action === 'set_user_jira_filter') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$targetUserId = (int)($input['user_id'] ?? $user['id']);
|
||||
$filterCode = trim($input['filter_code'] ?? '');
|
||||
|
||||
if ($targetUserId !== (int)$user['id'] && ($user['role'] ?? '') !== 'ADMIN') {
|
||||
echo json_encode(['error' => 'Geen toestemming om instellingen van andere gebruikers te wijzigen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE users SET default_jira_filter_code = ? WHERE id = ?");
|
||||
$stmt->execute([$filterCode ?: null, $targetUserId]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Wachtrijvoorkeur is succesvol bijgewerkt.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende actie']);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Live Sync & Realtime Notificatie Hub
|
||||
* Controleert continu op nieuwe Outlook e-mails, relevante Jira tickets en agenda updates.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
require_once __DIR__ . '/../includes/integrations/jira.php';
|
||||
|
||||
$user = require_auth();
|
||||
$userId = (int)$user['id'];
|
||||
$db = get_db_connection();
|
||||
|
||||
// Geef het PHP sessieslot direct vrij zodat gelijktijdige paginanavigatie nooit geblokkeerd wordt
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
$nowTs = time();
|
||||
$nowIso = date('Y-m-d H:i:s');
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$clientLastMailId = trim($_GET['last_mail_id'] ?? '');
|
||||
$clientLastJiraKey = trim($_GET['last_jira_key'] ?? '');
|
||||
$clientCalendarHash = trim($_GET['calendar_hash'] ?? '');
|
||||
|
||||
// 1. Outlook E-mail Controle
|
||||
$mailRes = microsoft_fetch_inbox_messages($userId, 5);
|
||||
$messages = $mailRes['messages'] ?? [];
|
||||
$unreadCount = (int)($mailRes['unread_count'] ?? 0);
|
||||
$latestMail = $messages[0] ?? null;
|
||||
|
||||
$hasNewMail = false;
|
||||
$newMailPayload = null;
|
||||
|
||||
if ($latestMail && !empty($latestMail['id'])) {
|
||||
// Als de client een ander ID heeft dan de allernieuwste mail en het bericht is ongelezen
|
||||
if (!empty($clientLastMailId) && $latestMail['id'] !== $clientLastMailId && empty($latestMail['is_read'])) {
|
||||
$hasNewMail = true;
|
||||
$newMailPayload = [
|
||||
'id' => $latestMail['id'],
|
||||
'subject' => $latestMail['subject'],
|
||||
'from_name' => $latestMail['from_name'],
|
||||
'from_email' => $latestMail['from_email'],
|
||||
'received_human' => $latestMail['received_human'],
|
||||
'web_link' => $latestMail['web_link'] ?? '/mail.php',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Jira Tickets Controle (rekening houdend met persoonlijke toewijzing & default queue filter zoals DX of SD)
|
||||
$userFilterCode = $user['default_jira_filter_code'] ?? '';
|
||||
$jiraData = jira_get_user_issues($userId);
|
||||
$assignedIssues = $jiraData['assigned_issues'] ?? ($jiraData['issues'] ?? []);
|
||||
$teamIssues = $jiraData['team_issues'] ?? [];
|
||||
|
||||
$dxValues = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie'];
|
||||
$relevantIssues = [];
|
||||
|
||||
// Filter issues die specifiek relevant zijn voor deze medewerker
|
||||
$userFirstName = explode(' ', trim($user['name'] ?? ''))[0] ?? '';
|
||||
foreach (array_merge($assignedIssues, $teamIssues) as $iss) {
|
||||
$assignee = $iss['assignee_name'] ?? '';
|
||||
$isAssignedToMe = !empty($assignee) && (
|
||||
stripos($assignee, $user['name'] ?? '') !== false ||
|
||||
(!empty($userFirstName) && mb_strlen($userFirstName) >= 2 && stripos($assignee, $userFirstName) !== false) ||
|
||||
(!empty($user['email']) && stripos($assignee, $user['email']) !== false)
|
||||
);
|
||||
$ol = strtolower(trim($iss['onderwerp_logging'] ?? ''));
|
||||
|
||||
$isQueueMatch = false;
|
||||
if (!empty($userFilterCode)) {
|
||||
if ($userFilterCode === 'DX' && in_array($ol, $dxValues)) {
|
||||
$isQueueMatch = true;
|
||||
} elseif ($userFilterCode === 'SD' && ($ol === '' || $ol === 'empty' || !in_array($ol, $dxValues))) {
|
||||
$isQueueMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isAssignedToMe || $isQueueMatch) {
|
||||
$relevantIssues[$iss['key']] = $iss;
|
||||
}
|
||||
}
|
||||
|
||||
$hasNewJira = false;
|
||||
$newJiraPayload = [];
|
||||
$newestJiraKey = '';
|
||||
|
||||
if (!empty($relevantIssues)) {
|
||||
// Pak het nieuwste ticket
|
||||
$newest = reset($relevantIssues);
|
||||
$newestJiraKey = $newest['key'];
|
||||
|
||||
if (!empty($clientLastJiraKey) && $newestJiraKey !== $clientLastJiraKey) {
|
||||
$hasNewJira = true;
|
||||
$newJiraPayload = [
|
||||
'key' => $newest['key'],
|
||||
'summary' => $newest['summary'],
|
||||
'priority' => $newest['priority'] ?? 'Medium',
|
||||
'issue_type' => $newest['issue_type'] ?? 'Taak',
|
||||
'project' => $newest['project'] ?? 'SD',
|
||||
'url' => $newest['url'] ?? ('/jira.php?highlight=' . $newest['key']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Outlook Agenda Controle (Tijdlijn events van vandaag)
|
||||
$calendarChanged = false;
|
||||
$todayEvents = [];
|
||||
$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
|
||||
$stmtInt->execute([$userId]);
|
||||
$outlookInt = $stmtInt->fetch();
|
||||
|
||||
if ($outlookInt && !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode'])) {
|
||||
$todayEvents = microsoft_fetch_today_events($userId, $today);
|
||||
}
|
||||
|
||||
$calHash = md5(json_encode(array_map(function($e) {
|
||||
return ($e['id'] ?? '') . ($e['start'] ?? '') . ($e['end'] ?? '') . ($e['subject'] ?? '');
|
||||
}, $todayEvents)));
|
||||
|
||||
if (!empty($clientCalendarHash) && $calHash !== $clientCalendarHash) {
|
||||
$calendarChanged = true;
|
||||
}
|
||||
|
||||
// 4. Update sync timestamps in database
|
||||
$stmtUpdate = $db->prepare("UPDATE users SET last_mail_check_at = NOW(), last_jira_check_at = NOW() WHERE id = ?");
|
||||
$stmtUpdate->execute([$userId]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'server_time' => date('H:i:s'),
|
||||
'mail' => [
|
||||
'has_new' => $hasNewMail,
|
||||
'unread_count' => $unreadCount,
|
||||
'latest_id' => $latestMail['id'] ?? '',
|
||||
'new_mail' => $newMailPayload,
|
||||
],
|
||||
'jira' => [
|
||||
'has_new' => $hasNewJira,
|
||||
'newest_key' => $newestJiraKey,
|
||||
'new_issue' => $newJiraPayload,
|
||||
],
|
||||
'calendar' => [
|
||||
'has_changes' => $calendarChanged,
|
||||
'hash' => $calHash,
|
||||
'count' => count($todayEvents),
|
||||
],
|
||||
]);
|
||||
exit;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Microsoft Outlook 365 E-mail API Endpoint
|
||||
* Verwerkt ophalen van inbox, detailberichten, beantwoorden, doorsturen en 1-klik inplannen.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
require_once __DIR__ . '/../includes/boards.php';
|
||||
|
||||
$user = require_auth();
|
||||
$userId = (int)$user['id'];
|
||||
$db = get_db_connection();
|
||||
|
||||
// Geef het PHP sessieslot direct vrij zodat paginanavigatie nooit geblokkeerd wordt
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||
|
||||
try {
|
||||
// 0. Mail mappen ophalen
|
||||
if ($action === 'get_folders') {
|
||||
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
|
||||
$res = microsoft_fetch_mail_folders($userId, $forceRefresh);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 0b. Uitgeschakelde mappen opslaan
|
||||
if ($action === 'save_disabled_folders') {
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
$folders = $data['disabled_folders'] ?? ($_POST['disabled_folders'] ?? []);
|
||||
if (is_string($folders)) {
|
||||
$folders = json_decode($folders, true) ?: [];
|
||||
}
|
||||
if (!is_array($folders)) {
|
||||
$folders = [];
|
||||
}
|
||||
$ok = microsoft_save_disabled_folders($userId, $folders);
|
||||
echo json_encode(['success' => $ok, 'disabled_folders' => $folders]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1. Inbox of specifieke map berichten ophalen
|
||||
if ($action === 'get_folder_messages' || $action === 'get_inbox') {
|
||||
$folderId = trim($_GET['folder_id'] ?? ($_POST['folder_id'] ?? 'inbox'));
|
||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 30;
|
||||
$unreadOnly = !empty($_GET['unread_only']) && $_GET['unread_only'] === '1';
|
||||
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
|
||||
|
||||
$res = microsoft_fetch_folder_messages($userId, $folderId, $limit, $unreadOnly, $forceRefresh);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Enkel bericht detail ophalen
|
||||
if ($action === 'get_message') {
|
||||
$messageId = trim($_GET['id'] ?? ($_POST['id'] ?? ''));
|
||||
if (empty($messageId)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Geen bericht-ID opgegeven.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$detail = microsoft_fetch_message_detail($userId, $messageId);
|
||||
if (!$detail) {
|
||||
echo json_encode(['success' => false, 'error' => 'Bericht niet gevonden of geen toegang.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => $detail]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Markeer als gelezen of ongelezen
|
||||
if ($action === 'mark_read') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$messageId = trim($input['id'] ?? '');
|
||||
$isRead = isset($input['is_read']) ? (bool)$input['is_read'] : true;
|
||||
|
||||
if (empty($messageId)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Geen bericht-ID opgegeven.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ok = microsoft_mark_message_read($userId, $messageId, $isRead);
|
||||
echo json_encode(['success' => $ok]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. E-mail beantwoorden
|
||||
if ($action === 'reply') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$messageId = trim($input['id'] ?? '');
|
||||
$comment = trim($input['comment'] ?? '');
|
||||
$replyAll = !empty($input['reply_all']);
|
||||
|
||||
if (empty($messageId) || empty($comment)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Bericht-ID en reactietekst zijn verplicht.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$res = microsoft_reply_message($userId, $messageId, $comment, $replyAll);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. E-mail doorsturen
|
||||
if ($action === 'forward') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$messageId = trim($input['id'] ?? '');
|
||||
$toInput = $input['to'] ?? '';
|
||||
$comment = trim($input['comment'] ?? '');
|
||||
|
||||
if (empty($messageId) || empty($toInput)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Bericht-ID en minimaal één geadresseerde zijn verplicht.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$recipients = [];
|
||||
if (is_array($toInput)) {
|
||||
$recipients = $toInput;
|
||||
} else {
|
||||
$parts = explode(',', (string)$toInput);
|
||||
foreach ($parts as $p) {
|
||||
$p = trim($p);
|
||||
if (filter_var($p, FILTER_VALIDATE_EMAIL)) {
|
||||
$recipients[] = ['address' => $p, 'name' => $p];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($recipients)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Geen geldige e-mailadressen ingevoerd.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$res = microsoft_forward_message($userId, $messageId, $recipients, $comment);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 6. E-mail direct inplannen in Fociz dagplanning
|
||||
if ($action === 'plan_email') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$messageId = trim($input['message_id'] ?? $input['mail_id'] ?? '');
|
||||
$subject = trim($input['title'] ?? $input['subject'] ?? 'E-mail actiepunt');
|
||||
$planDate = trim($input['plan_date'] ?? $input['date'] ?? date('Y-m-d'));
|
||||
$timeBlockStart = !empty($input['time_block_start']) ? trim($input['time_block_start']) : null;
|
||||
$durationMinutes = (int)($input['duration_minutes'] ?? 30);
|
||||
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
|
||||
$webLink = trim($input['web_link'] ?? 'https://outlook.office.com/mail/');
|
||||
$sender = trim($input['sender'] ?? $input['sender_name'] ?? '');
|
||||
|
||||
if (empty($subject)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Onderwerp is verplicht.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($durationMinutes <= 0) $durationMinutes = 30;
|
||||
|
||||
$timeBlockEnd = !empty($input['time_block_end']) ? trim($input['time_block_end']) : null;
|
||||
if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
|
||||
try {
|
||||
$dtEnd = new DateTime("{$planDate} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||
$dtEnd->modify("+{$durationMinutes} minutes");
|
||||
$timeBlockEnd = $dtEnd->format('H:i');
|
||||
} catch (Throwable $t) {}
|
||||
}
|
||||
|
||||
// Zorg dat daily_plan bestaat
|
||||
$stmtPlan = $db->prepare("
|
||||
INSERT INTO daily_plans (user_id, plan_date)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
|
||||
");
|
||||
$stmtPlan->execute([$userId, $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
|
||||
// Als er een custom titel is ingevoerd, gebruik die direct, anders maak een nette titel
|
||||
$taskTitle = $subject;
|
||||
if (isset($input['subject']) && !isset($input['title'])) {
|
||||
$taskTitle = "✉️ " . $subject;
|
||||
if (!empty($sender) && strpos($taskTitle, $sender) === false) {
|
||||
$taskTitle .= " (van " . $sender . ")";
|
||||
}
|
||||
}
|
||||
|
||||
$source = 'OUTLOOK_MAIL';
|
||||
$externalId = $messageId;
|
||||
$externalUrl = $webLink;
|
||||
$outlookSyncMsg = null;
|
||||
|
||||
// Automatisch synchroniseren naar Microsoft Outlook agenda
|
||||
if (function_exists('microsoft_create_event')) {
|
||||
try {
|
||||
$syncStartTime = !empty($timeBlockStart) ? $timeBlockStart : '09:00';
|
||||
$msResult = microsoft_create_event($userId, [
|
||||
'subject' => $taskTitle,
|
||||
'description' => "E-mail actiepunt gepland via Fociz ({$priority})\n\nAfzender: {$sender}\nWeblink: {$webLink}",
|
||||
'date' => $planDate,
|
||||
'start_time' => $syncStartTime,
|
||||
'end_time' => $timeBlockEnd,
|
||||
'duration_minutes' => $durationMinutes,
|
||||
]);
|
||||
|
||||
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||
$source = 'OUTLOOK';
|
||||
$externalId = $msResult['event_id'];
|
||||
$externalUrl = $msResult['web_link'] ?? ($webLink ?: 'https://outlook.office.com');
|
||||
$timeBlockStart = $msResult['start_time'] ?? $syncStartTime;
|
||||
$timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd;
|
||||
$outlookSyncMsg = 'Gesynchroniseerd naar Microsoft Outlook.';
|
||||
}
|
||||
} catch (Throwable $t) {
|
||||
error_log("Fout bij auto-sync Mail naar Outlook: " . $t->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$stmtTask = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, source, external_id, external_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmtTask->execute([
|
||||
$userId,
|
||||
$planId,
|
||||
$taskTitle,
|
||||
$priority,
|
||||
$timeBlockStart,
|
||||
$timeBlockEnd,
|
||||
$durationMinutes,
|
||||
$source,
|
||||
$externalId,
|
||||
$externalUrl
|
||||
]);
|
||||
$taskId = (int)$db->lastInsertId();
|
||||
|
||||
// Optioneel toevoegen aan Bord en Tijdlijn
|
||||
$boardSyncResult = null;
|
||||
$addToBoard = !empty($input['add_to_board']) && !empty($input['board_id']);
|
||||
if ($addToBoard) {
|
||||
$boardId = (int)$input['board_id'];
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$scheduleOnTimeline = !empty($input['schedule_on_timeline']);
|
||||
$timelineStartDate = !empty($input['timeline_start_date']) ? trim($input['timeline_start_date']) : $planDate;
|
||||
$timelineEndDate = !empty($input['timeline_end_date']) ? trim($input['timeline_end_date']) : $timelineStartDate;
|
||||
|
||||
$mailDesc = "Afzender: " . ($sender ?: 'Onbekend');
|
||||
if (!empty($webLink)) {
|
||||
$mailDesc .= "\nWeblink: " . $webLink;
|
||||
}
|
||||
|
||||
$boardSyncResult = fociz_add_item_to_board_and_timeline($db, $userId, (($user['role'] ?? '') === 'ADMIN'), [
|
||||
'board_id' => $boardId,
|
||||
'lane_id' => $laneId,
|
||||
'title' => $taskTitle,
|
||||
'description' => $mailDesc,
|
||||
'priority' => $priority,
|
||||
'color' => !empty($input['board_color']) ? $input['board_color'] : 'blue',
|
||||
'schedule_on_timeline' => $scheduleOnTimeline,
|
||||
'start_date' => $timelineStartDate,
|
||||
'end_date' => $timelineEndDate,
|
||||
'start_time' => $timeBlockStart ?: '09:00',
|
||||
'end_time' => $timeBlockEnd ?: '10:00',
|
||||
]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'plan_date' => $planDate,
|
||||
'outlook_synced' => ($source === 'OUTLOOK'),
|
||||
'board_sync' => $boardSyncResult,
|
||||
'message' => "E-mail succesvol ingepland voor {$planDate}." . ($outlookSyncMsg ? " ({$outlookSyncMsg})" : "")
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => false, 'error' => 'Onbekende e-mailactie.']);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
$user = require_auth();
|
||||
$db = get_db_connection();
|
||||
|
||||
// Geef het PHP sessieslot direct vrij zodat paginanavigatie nooit geblokkeerd wordt
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||
|
||||
@@ -53,6 +53,30 @@ try {
|
||||
$fields[] = "status = ?";
|
||||
$params[] = $status;
|
||||
}
|
||||
if (isset($data['quarter']) && in_array($data['quarter'], ['Q1', 'Q2', 'Q3', 'Q4'], true)) {
|
||||
$fields[] = "quarter = ?";
|
||||
$params[] = $data['quarter'];
|
||||
}
|
||||
if (isset($data['title']) && trim($data['title']) !== '') {
|
||||
$fields[] = "title = ?";
|
||||
$params[] = trim($data['title']);
|
||||
}
|
||||
if (isset($data['description'])) {
|
||||
$fields[] = "description = ?";
|
||||
$params[] = trim($data['description']);
|
||||
}
|
||||
if (isset($data['type'])) {
|
||||
$fields[] = "type = ?";
|
||||
$params[] = $data['type'];
|
||||
}
|
||||
if (isset($data['priority'])) {
|
||||
$fields[] = "priority = ?";
|
||||
$params[] = $data['priority'];
|
||||
}
|
||||
if (array_key_exists('due_date', $data)) {
|
||||
$fields[] = "due_date = ?";
|
||||
$params[] = !empty($data['due_date']) ? $data['due_date'] : null;
|
||||
}
|
||||
if ($progress !== null) {
|
||||
$fields[] = "progress = ?";
|
||||
$params[] = max(0, min(100, $progress));
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Session Guard API (Release v1.6)
|
||||
* Ondersteunt realtime session keepalive, inactiviteitstimeout status en veilige logout
|
||||
* Conform NIST SP 800-63B, OWASP ASVS v4.0 en ISO 27001
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/settings.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$currentUser = require_auth(true);
|
||||
$userId = (int)($currentUser['id'] ?? 0);
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? 'keepalive');
|
||||
$idleTimeout = get_session_idle_timeout();
|
||||
$now = time();
|
||||
|
||||
if ($action === 'keepalive' || $action === 'ping') {
|
||||
// Verleng de activiteitstimer van de sessie
|
||||
$_SESSION['last_activity'] = $now;
|
||||
$_SESSION['last_db_activity_update'] = $now;
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?");
|
||||
$stmtAct->execute([$userId]);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'remaining' => $idleTimeout,
|
||||
'timeout' => $idleTimeout,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'status') {
|
||||
$lastActivity = (int)($_SESSION['last_activity'] ?? $now);
|
||||
$elapsed = $now - $lastActivity;
|
||||
$remaining = max(0, $idleTimeout - $elapsed);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'remaining' => $remaining,
|
||||
'timeout' => $idleTimeout,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
fociz_terminate_session(session_id());
|
||||
fociz_log_audit($userId, 'LOGOUT_SESSION_GUARD', 'users', $userId, ['ip' => fociz_get_client_ip()], 'SUCCESS', $currentUser['email'] ?? null);
|
||||
} catch (Throwable $e) {
|
||||
error_log("Session guard logout error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
clear_day_session_cookie();
|
||||
$_SESSION = [];
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
@session_destroy();
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Succesvol uitgelogd.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Haal actieve apparaten en sessies op (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if ($action === 'get_active_sessions') {
|
||||
$sessions = fociz_get_user_active_sessions($userId, session_id());
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'sessions' => $sessions,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Beëindig alle overige actieve sessies (SOC 2 CC6.1)
|
||||
if ($action === 'revoke_other_sessions') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig CSRF beveiligingstoken.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$count = fociz_revoke_other_sessions($userId, session_id());
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'revoked_count' => $count,
|
||||
'message' => "Succesvol {$count} andere actieve sessie(s) op afstand beëindigd."
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Beëindig specifieke sessie op afstand
|
||||
if ($action === 'revoke_session') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig CSRF beveiligingstoken.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sessionDbId = (int)($_POST['session_id'] ?? 0);
|
||||
$ok = fociz_revoke_session_by_id($userId, $sessionDbId);
|
||||
echo json_encode([
|
||||
'success' => $ok,
|
||||
'message' => $ok ? 'Sessie succesvol op afstand beëindigd.' : 'Kan sessie niet intrekken.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Onbekende sessieactie.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Thema API Endpoint (Dark / Light / System)
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
$user = require_auth(true);
|
||||
$userId = (int)$user['id'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$currentTheme = get_user_theme($userId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'theme' => $currentTheme,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
$theme = $data['theme'] ?? ($_POST['theme'] ?? 'dark');
|
||||
if (!in_array($theme, ['dark', 'light', 'system'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig thema. Kies dark, light of system.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ok = update_user_theme($userId, $theme);
|
||||
if ($ok) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'theme' => $theme,
|
||||
'message' => 'Themavoorkeur succesvol opgeslagen.',
|
||||
]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Kon thema niet opslaan in database.']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* Jira Webhook Receiver
|
||||
* Ontvangt webhook events van Atlassian Jira Cloud (issue_created, issue_updated, etc.)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../config/database.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Beveiligingscontrole: Verifieer geheime webhook token. Weiger toegang als secret ontbreekt.
|
||||
$configuredSecret = fociz_env('JIRA_WEBHOOK_SECRET', '');
|
||||
if (empty($configuredSecret)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Jira webhooks zijn niet ingeschakeld of geconfigureerd op deze server.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$incomingSecret = $_GET['secret'] ?? ($_SERVER['HTTP_X_ATLASSIAN_WEBHOOK_SECRET'] ?? '');
|
||||
if (empty($incomingSecret) || !hash_equals($configuredSecret, $incomingSecret)) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Onbevoegde webhook aanroep']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawPayload = file_get_contents('php://input');
|
||||
$data = json_decode($rawPayload, true);
|
||||
|
||||
if (!$data) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Ongeldige JSON payload']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$event = $data['webhookEvent'] ?? 'jira:unknown';
|
||||
$issueKey = $data['issue']['key'] ?? '';
|
||||
$summary = $data['issue']['fields']['summary'] ?? '';
|
||||
|
||||
// Sla event tijdelijk op of invalidate session cache
|
||||
error_log("Jira Webhook ontvangen: {$event} voor {$issueKey} - {$summary}");
|
||||
|
||||
echo json_encode([
|
||||
'received' => true,
|
||||
'event' => $event,
|
||||
'issue_key' => $issueKey,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
exit;
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $t->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
/**
|
||||
* Microsoft Graph Webhook Receiver
|
||||
* Ontvangt subscription notifications voor /me/mailFolders('inbox')/messages en /me/events
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../config/database.php';
|
||||
|
||||
// Validatie bij aanmaken van Graph subscription
|
||||
if (isset($_GET['validationToken'])) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
http_response_code(200);
|
||||
echo $_GET['validationToken'];
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if ($data && isset($data['value'])) {
|
||||
foreach ($data['value'] as $change) {
|
||||
$resource = $change['resource'] ?? '';
|
||||
$changeType = $change['changeType'] ?? '';
|
||||
error_log("Microsoft Graph Webhook: {$changeType} op {$resource}");
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['received' => true]);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#6366f1" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M21 29a19.88 19.88 0 0 1 39 0c-9.72-7.8-30.4-7.44-39 0Z" fill="#2B1607"/><path d="M20.58 29.54c-.1 0-.2-.03-.3-.09a.58.58 0 0 1-.24-.27.56.56 0 0 1-.02-.36c2.48-9.9 10.54-16.22 20.51-16.22 9.98 0 18.01 6.41 20.45 16.28.03.11.02.24-.02.35a.58.58 0 0 1-.23.27.62.62 0 0 1-.7 0c-9.4-7.44-30.24-7.44-39.1 0a.6.6 0 0 1-.35.04Zm19.74-6.8c7.07 0 14.22 1.6 19.11 4.77-1.15-3.98-3.62-7.5-7.03-10a19.88 19.88 0 0 0-11.83-3.82 19.94 19.94 0 0 0-11.82 3.8 19.02 19.02 0 0 0-7.06 9.97c4.58-3.14 11.6-4.71 18.63-4.71Z" fill="#000"/><path d="M65 41.02a4.86 4.86 0 0 1-1.42 3.5 5.04 5.04 0 0 1-7.08.08 4.9 4.9 0 0 1-1.5-3.47c0-3.94 3.16-5.81 4.84-12.13 1.53 6.15 5.12 7.66 5.16 12.02Z" fill="#4ABCF4"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M74.38 30a13 13 0 0 0 13-13v-.66C87.38 5.9 75.83-.87 68.45.1c-6.54.84-7.07 5.02-7.07 11.24V17a13.01 13.01 0 0 0 13 13Z" fill="#000"/><path d="M70.2 9.67a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.44 18.48a5.32 5.32 0 1 0 0-10.64 5.32 5.32 0 0 0 0 10.64Z" fill="#fff"/><path d="M13.37 30a13.01 13.01 0 0 1-13-13v-.66C.38 5.9 11.93-.87 19.32.1c6.56.84 7.07 5.02 7.07 11.24V17a13 13 0 0 1-13 13Z" fill="#000"/><path d="M9.22 9.67a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.46 18.48a5.32 5.32 0 1 0 0-10.64 5.32 5.32 0 0 0 0 10.64Z" fill="#fff"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#ec4899" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4c10.24 32.68 61.73 31.31 72 0A167.66 167.66 0 0 1 4 4Z" fill="#2B1607"/><path d="M39.68 29h-.64c-17.41-.24-31.54-9.95-36-24.74a1.02 1.02 0 0 1 .2-.95.98.98 0 0 1 .91-.28 165.35 165.35 0 0 0 71.72 0 .91.91 0 0 1 .9.3 1.04 1.04 0 0 1 .19.97C71.93 19.37 57.36 29 39.68 29ZM5.33 5.3c4.73 13.13 17.76 21.54 33.7 21.77C55.6 27.3 69.29 18.8 74.56 5.3a167.24 167.24 0 0 1-69.22 0Z" fill="#000"/><g style="mix-blend-mode:soft-light" opacity=".4"><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" fill="#422715"/><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" stroke="#000" stroke-width="1.58" stroke-miterlimit="10"/></g><path fill-rule="evenodd" clip-rule="evenodd" d="M40.83 45H38.1c-2.67 0-5.24-1.06-7.13-2.94a10 10 0 0 1-2.96-7.1V19.54c0-1.9 2.8-3.94 4.88-3.46a29.03 29.03 0 0 0 13.26 0C48 15.7 51 17.62 51 19.53v15.44a9.98 9.98 0 0 1-6.28 9.29c-1.23.5-2.55.75-3.89.74Z" fill="url(#mouthTongueOut-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M38.27 17c.95 3.75.33 13 1.13 13 .8 0 .53-9.01 1.37-13h-2.5Z" fill="#A00707"/><defs><linearGradient id="mouthTongueOut-a" x1="39.46" y1="45" x2="39.46" y2="16" gradientUnits="userSpaceOnUse"><stop stop-color="#EF0A0A"/><stop offset=".5" stop-color="#ED0A0A"/><stop offset=".67" stop-color="#E60A0A"/><stop offset=".8" stop-color="#DB0A0A"/><stop offset=".9" stop-color="#CA0A0A"/><stop offset=".99" stop-color="#B40A0A"/><stop offset="1" stop-color="#B10A0A"/></linearGradient></defs></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M74.46 30.06a13 13 0 0 1-13-13v-.66C61.46 5.95 73.01-.82 80.39.15c6.54.83 7.07 5.02 7.07 11.24v5.67a13.01 13.01 0 0 1-13 13Z" fill="#000"/><path d="M70.28 9.73a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.52 18.54a5.32 5.32 0 1 0 0-10.65 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M13.47 30.06a13.01 13.01 0 0 0 12.99-13v-.66C26.46 5.95 14.92-.82 7.53.15.97.98.46 5.17.46 11.39v5.67a13 13 0 0 0 13 13Z" fill="#000"/><path d="M9.3 9.73a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.54 18.54a5.32 5.32 0 1 0 0-10.65 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#10b981" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M40.54 12h-.75c-9.7.22-20.8 5.3-23.7 16.15a1.36 1.36 0 0 0 .44 1.55 1.41 1.41 0 0 0 2.26-.86c2.55-9.46 12.42-13.89 21.06-14.08 8.24-.16 19.04 3.84 22.46 14.57a1.47 1.47 0 0 0 1.65.55A1.44 1.44 0 0 0 65 28.5C61.85 18.69 52.3 12 40.54 12Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M74.38 30a13 13 0 0 0 13-13v-.66C87.38 5.9 75.83-.87 68.45.1c-6.54.84-7.07 5.02-7.07 11.24V17a13.01 13.01 0 0 0 13 13Z" fill="#000"/><path d="M70.2 9.67a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.44 18.48a5.32 5.32 0 1 0 0-10.64 5.32 5.32 0 0 0 0 10.64Z" fill="#fff"/><path d="M13.37 30a13.01 13.01 0 0 1-13-13v-.66C.38 5.9 11.93-.87 19.32.1c6.56.84 7.07 5.02 7.07 11.24V17a13 13 0 0 1-13 13Z" fill="#000"/><path d="M9.22 9.67a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.46 18.48a5.32 5.32 0 1 0 0-10.64 5.32 5.32 0 0 0 0 10.64Z" fill="#fff"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#8b5cf6" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M40.54 30h-.75c-9.7-.22-20.8-5.3-23.7-16.15a1.36 1.36 0 0 1 .44-1.55 1.41 1.41 0 0 1 2.26.86c2.55 9.46 12.42 13.89 21.06 14.08 8.24.16 19.04-3.84 22.46-14.57a1.47 1.47 0 0 1 1.65-.55A1.44 1.44 0 0 1 65 13.5C61.85 23.31 52.3 30 40.54 30Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.76 21.94c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 .49-1.92 1.7 1.7 0 0 1 2.76.9c.6 2.12 1.88 3.98 3.62 5.29a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.24.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.76 21.94c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.06 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.22 10.22 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.87 7.1 13.19 13.19 0 0 1-8.08 2.65Z" fill="#000"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#f59e0b" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M76 1v.49a35.13 35.13 0 0 1-.9 7.75 30.24 30.24 0 0 1-14.24 19.24C48.32 36 30.66 35.84 18.37 28A30.01 30.01 0 0 1 4.8 8.66 36.4 36.4 0 0 1 4 1c22.86 8.83 46.05 8.83 69.54.9L76 1Z" fill="#2B1607" stroke="#000" stroke-width="1.48" stroke-linecap="round" stroke-linejoin="round"/><path fill-rule="evenodd" clip-rule="evenodd" d="M76 1.48a33.97 33.97 0 0 1-.9 7.62C54.26 22.59 25.62 22.2 4.8 8.53 4.25 6.06 4 3.53 4 1c22.85 8.67 46.03 8.67 69.51.88l2.49-.4ZM62 28.49c-12.99 7.5-31.29 7.34-44-.49a134.2 134.2 0 0 0 44 .49Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M76.57.19c.27.19.43.5.43.83 0 11.3-4.64 19.83-11.62 25.51C58.42 32.2 49.18 35 40 35c-9.19 0-18.42-2.8-25.38-8.47C7.64 20.85 3 12.33 3 1.03A1.03 1.03 0 0 1 4.38.06c23.45 9.02 47.14 8.78 71.25 0 .32-.12.66-.08.94.12ZM5.07 2.5C5.45 12.44 9.7 19.9 15.9 24.94c6.54 5.32 15.3 8.01 24.1 8.01s17.56-2.69 24.1-8c6.2-5.06 10.45-12.53 10.83-22.46C51.36 10.77 28.1 11 5.07 2.5Z" fill="#000"/><path d="M24.01 36.05c4.5 5.16 25.03 5.5 30 0a68.03 68.03 0 0 1-30 0Z" fill="#000" style="mix-blend-mode:soft-light" opacity=".4"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M87.22 13.98c0-7.2-5.82-13.04-13-13.04s-13 5.84-13 13.04v3.92c0 7.2 5.82 13.04 13 13.04s13-5.83 13-13.04v-3.92Z" fill="#000"/><path d="M70 10.48a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.24 19.3a5.32 5.32 0 1 0 0-10.66 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M26.22 13.98c0-7.2-5.82-13.04-13-13.04s-13 5.84-13 13.04v3.92c0 7.2 5.82 13.04 13 13.04s13-5.83 13-13.04v-3.92Z" fill="#000"/><path d="M9 10.48A2.29 2.29 0 1 0 9 5.9a2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.24 19.3a5.32 5.32 0 1 0 0-10.66 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M84.33-5.7H65.45C58.63-5.7 53.1-.2 53.1 6.6v18.8c0 6.79 5.52 12.3 12.34 12.3h18.88c6.81 0 12.34-5.51 12.34-12.3V6.6c0-6.8-5.53-12.3-12.34-12.3Z" fill="url(#eyesGlasses-a)"/><path d="M21.3-5.7H2.42C-4.4-5.7-9.92-.2-9.92 6.6v18.8c0 6.79 5.52 12.3 12.34 12.3H21.3c6.81 0 12.34-5.51 12.34-12.3V6.6c0-6.8-5.53-12.3-12.34-12.3Z" fill="url(#eyesGlasses-b)"/><g fill="#000"><path d="M21.06 40.12H2.18A14.83 14.83 0 0 1-8.2 35.81a14.71 14.71 0 0 1-4.33-10.34V6.6c.02-3.87 1.58-7.59 4.34-10.34A14.85 14.85 0 0 1 2.18-8.06h18.88c3.92.02 7.66 1.58 10.42 4.35 2.76 2.76 4.31 6.5 4.31 10.4v18.7A14.62 14.62 0 0 1 26.71 39c-1.79.74-3.7 1.12-5.65 1.12ZM2.18-3.26A9.96 9.96 0 0 0-7 2.83a9.85 9.85 0 0 0-.76 3.78v18.8a9.85 9.85 0 0 0 9.9 9.86h18.92a9.93 9.93 0 0 0 9.9-9.86V6.6a9.87 9.87 0 0 0-9.9-9.86H2.18ZM84.33 40.12H65.46a14.83 14.83 0 0 1-10.39-4.31 14.71 14.71 0 0 1-4.33-10.34V6.6c.02-3.87 1.58-7.59 4.34-10.34a14.85 14.85 0 0 1 10.38-4.32h18.87c3.9.03 7.65 1.59 10.41 4.35s4.31 6.5 4.32 10.4v18.7c0 3.9-1.56 7.64-4.32 10.4a14.83 14.83 0 0 1-10.41 4.33ZM65.46-3.26a9.93 9.93 0 0 0-9.9 9.87v18.8a9.85 9.85 0 0 0 9.9 9.86h18.87a9.93 9.93 0 0 0 9.9-9.86V6.6a9.85 9.85 0 0 0-9.9-9.86H65.46Z"/><path d="M53.1 10.64H33.4v4.89h19.7v-4.89Z"/></g><defs><linearGradient id="eyesGlasses-a" x1="2332.67" y1="1561.82" x2="3621.21" y2="1561.82" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#969696" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity=".3"/></linearGradient><linearGradient id="eyesGlasses-b" x1="2269.64" y1="1561.82" x2="3558.18" y2="1561.82" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#969696" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity=".3"/></linearGradient></defs></g></g></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#f43f5e" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4c10.24 32.68 61.73 31.31 72 0A167.66 167.66 0 0 1 4 4Z" fill="#2B1607"/><path d="M39.68 29h-.64c-17.41-.24-31.54-9.95-36-24.74a1.02 1.02 0 0 1 .2-.95.98.98 0 0 1 .91-.28 165.35 165.35 0 0 0 71.72 0 .91.91 0 0 1 .9.3 1.04 1.04 0 0 1 .19.97C71.93 19.37 57.36 29 39.68 29ZM5.33 5.3c4.73 13.13 17.76 21.54 33.7 21.77C55.6 27.3 69.29 18.8 74.56 5.3a167.24 167.24 0 0 1-69.22 0Z" fill="#000"/><g style="mix-blend-mode:soft-light" opacity=".4"><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" fill="#422715"/><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" stroke="#000" stroke-width="1.58" stroke-miterlimit="10"/></g><path fill-rule="evenodd" clip-rule="evenodd" d="M40.83 45H38.1c-2.67 0-5.24-1.06-7.13-2.94a10 10 0 0 1-2.96-7.1V19.54c0-1.9 2.8-3.94 4.88-3.46a29.03 29.03 0 0 0 13.26 0C48 15.7 51 17.62 51 19.53v15.44a9.98 9.98 0 0 1-6.28 9.29c-1.23.5-2.55.75-3.89.74Z" fill="url(#mouthTongueOut-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M38.27 17c.95 3.75.33 13 1.13 13 .8 0 .53-9.01 1.37-13h-2.5Z" fill="#A00707"/><defs><linearGradient id="mouthTongueOut-a" x1="39.46" y1="45" x2="39.46" y2="16" gradientUnits="userSpaceOnUse"><stop stop-color="#EF0A0A"/><stop offset=".5" stop-color="#ED0A0A"/><stop offset=".67" stop-color="#E60A0A"/><stop offset=".8" stop-color="#DB0A0A"/><stop offset=".9" stop-color="#CA0A0A"/><stop offset=".99" stop-color="#B40A0A"/><stop offset="1" stop-color="#B10A0A"/></linearGradient></defs></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.84 21.11c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 1.1-2.26 1.68 1.68 0 0 1 1.86.61c.14.2.24.4.3.64.6 2.11 1.87 3.97 3.61 5.28a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.23.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.84 21.11c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.05 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.21 10.21 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.86 7.1 13.19 13.19 0 0 1-8.1 2.65Z" fill="#000"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="90" ry="90" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#6366f1" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><mask id="sidesSquareAssymetric-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="10" y="8" width="165" height="60"><g fill="#fff"><rect x="10" y="31" width="36" height="30" rx="4"/><rect x="20" y="15" width="26" height="30" rx="4"/><rect x="139" y="23" width="36" height="30" rx="4"/><rect x="134" y="8" width="36" height="60" rx="4"/></g></mask><g mask="url(#sidesSquareAssymetric-a)"><path d="M0 0h180v76H0V0Z" fill="#d81b60"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#000" fill-opacity=".1" d="M0 47h180v29H0z"/><circle cx="161" cy="20" r="5" fill="#fff" fill-opacity=".6"/><circle cx="161" cy="36" r="5" fill="#fff" fill-opacity=".6"/></g></g><g transform="translate(41)"><mask id="topPyramid-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="8" width="64" height="44"><path fill-rule="evenodd" clip-rule="evenodd" d="m50 8 32 44H18L50 8Z" fill="#fff"/></mask><g mask="url(#topPyramid-a)"><path d="M0 0h100v52H0V0Z" fill="#d81b60"/><path d="M0 0h100v52H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#fff" fill-opacity=".8" d="M50 4h30v48H50z"/></g></g><g transform="translate(25 44)"><mask id="faceSquare04-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 102V68.85a40 40 0 0 1 2.28-13.31L19.76 12A18 18 0 0 1 36.74 0h56.52a18 18 0 0 1 16.98 12l17.48 43.54A40 40 0 0 1 130 68.85V102a18 18 0 0 1-18 18H18a18 18 0 0 1-18-18Z" fill="#fff"/></mask><g mask="url(#faceSquare04-a)"><path d="M-2-2h134v124H-2V-2Z" fill="#d81b60"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><path d="M27.05 8.44a2 2 0 1 1 3.9-.88C31.72 10.96 34.4 13 38 13c3.6 0 6.28-2.04 7.05-5.44a2 2 0 1 1 3.9.88C47.75 13.7 43.43 17 38 17s-9.76-3.3-10.95-8.56Z" fill="#000" fill-opacity=".6"/></g><g transform="translate(38 76)"><path d="m25 27.2 5.5 5.5c.5.4 1.2.4 1.6 0l1.6-1.6c.4-.5.4-1.2 0-1.6L28.2 24l5.5-5.5c.4-.5.4-1.2 0-1.6l-1.6-1.6c-.5-.4-1.2-.4-1.6 0L25 20.8l-5.5-5.5c-.5-.4-1.2-.4-1.6 0l-1.6 1.6c-.4.4-.4 1.1 0 1.6l5.5 5.5-5.5 5.5c-.4.5-.4 1.2 0 1.6l1.6 1.6c.5.4 1.2.4 1.6 0l5.5-5.5ZM79 27.2l5.5 5.5c.5.4 1.2.4 1.6 0l1.6-1.6c.4-.5.4-1.2 0-1.6L82.2 24l5.5-5.5c.4-.5.4-1.2 0-1.6l-1.6-1.6c-.5-.4-1.2-.4-1.6 0L79 20.8l-5.5-5.5c-.5-.4-1.2-.4-1.6 0l-1.6 1.6c-.4.4-.4 1.1 0 1.6l5.5 5.5-5.5 5.5c-.4.5-.4 1.2 0 1.6l1.6 1.6c.5.4 1.2.4 1.6 0l5.5-5.5Z" fill="#000" fill-opacity=".8"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="90" ry="90" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#10b981" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><g opacity=".9" stroke="#2A3544"><ellipse cx="32.5" cy="23" rx="16.5" ry="18" stroke-width="6"/><path d="M29.51 36.76c-7.4 4.29-17 1.55-21.42-6.1" stroke-width="4"/><ellipse cx="28.5" cy="52.5" rx="16.5" ry="14.5" stroke-width="4"/></g><g opacity=".9" stroke="#2A3544"><path d="M168.6 60.42c-4.27-7.41-13.95-9.84-21.6-5.42" stroke-width="4"/><ellipse cx="148.5" cy="22.5" rx="16.5" ry="15.5" stroke-width="6"/></g><mask id="sidesCables02-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="21" y="0" width="138" height="72"><g fill="#fff"><rect x="21" y="27" width="16" height="22" rx="2"/><rect x="22" y="60" width="16" height="12" rx="2"/><rect x="143" y="42" width="16" height="22" rx="2"/><rect x="143" width="16" height="22" rx="2"/></g></mask><g mask="url(#sidesCables02-a)"><path d="M0 0h180v76H0V0Z" fill="#e53935"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/></g></g><g transform="translate(41)"><g filter="url(#topGlowingBulb01-a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 24A16 16 0 0 1 48 8h4a16 16 0 0 1 16 16v8a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-8Z" fill="#fff" fill-opacity=".3"/></g><path d="M49 11.5c4.93 0 9.37 2.13 12.44 5.52" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="m49.83 29-9-9L38 22.83l10 10V40h4v-7.03l10.14-10.14L59.31 20l-9 9h-.48Z" fill="#fff" fill-opacity=".8"/><rect x="22" y="40" width="56" height="12" rx="1" fill="#48494B"/><defs><filter id="topGlowingBulb01-a" x="24" y="0" width="52" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="4"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_617_621"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_617_621" result="shape"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="2"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/><feBlend in2="shape" result="effect2_innerShadow_617_621"/></filter></defs></g><g transform="translate(25 44)"><mask id="faceSquare02-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><path d="M0 12A12 12 0 0 1 12 0h106a12 12 0 0 1 12 12v70a38 38 0 0 1-38 38H38A38 38 0 0 1 0 82V12Z" fill="#fff"/></mask><g mask="url(#faceSquare02-a)"><path d="M-2-2h134v124H-2V-2Z" fill="#e53935"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><g fill="#000" fill-opacity=".6"><rect x="28" y="10" width="6" height="14" rx="2"/><rect x="14" y="10" width="6" height="14" rx="2"/><rect x="42" y="10" width="6" height="14" rx="2"/><rect x="56" y="10" width="6" height="14" rx="2"/></g></g><g transform="translate(38 76)"><g fill-rule="evenodd" clip-rule="evenodd"><path d="M53 0c34.75 0 49 17.47 49 31 0 13.53-19.59 17-49 17-29.05 0-51-3.47-51-17S17.11 0 53 0Z" fill="#000" fill-opacity=".8"/><path d="M28.82 34.65c-6.53-1.35-11.24-6.34-10.52-11.14.72-4.79 6.6-7.58 13.12-6.23 6.53 1.36 11.24 6.35 10.52 11.15-.72 4.8-6.6 7.59-13.12 6.23ZM75.42 34.65c-6.52 1.36-12.4-1.43-13.12-6.23-.72-4.8 4-9.8 10.52-11.15 6.52-1.35 12.4 1.44 13.12 6.24.72 4.81-4 9.8-10.52 11.15Z" fill="#25A6F5"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="90" ry="90" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#8b5cf6" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><mask id="sidesSquareAssymetric-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="10" y="8" width="165" height="60"><g fill="#fff"><rect x="10" y="31" width="36" height="30" rx="4"/><rect x="20" y="15" width="26" height="30" rx="4"/><rect x="139" y="23" width="36" height="30" rx="4"/><rect x="134" y="8" width="36" height="60" rx="4"/></g></mask><g mask="url(#sidesSquareAssymetric-a)"><path d="M0 0h180v76H0V0Z" fill="#fdd835"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#000" fill-opacity=".1" d="M0 47h180v29H0z"/><circle cx="161" cy="20" r="5" fill="#fff" fill-opacity=".6"/><circle cx="161" cy="36" r="5" fill="#fff" fill-opacity=".6"/></g></g><g transform="translate(41)"><mask id="topHorns-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="8" y="12" width="84" height="40"><g fill="#fff"><path d="M8 40h26v12H8z"/><path transform="matrix(-1 0 0 1 92 40)" d="M0 0h26v12H0z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M16.52 13.74c0 7.84 5.39 20.71 13.72 27.04H11.36S7.84 22.66 13.43 14.1c.9-1.38 3.1-1.42 3.1-.36ZM84 14c.66 7.04-5.77 20.62-14 27h19s3.14-18.26-2-27c-1-1.7-3.14-1.45-3 0Z"/></g></mask><g mask="url(#topHorns-a)"><path d="M0 0h100v52H0V0Z" fill="#fdd835"/><path d="M0 0h100v52H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#000" fill-opacity=".4" d="M0 40h100v12H0z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M15.46 13h16.1v27H20.83c-7.45-7.85-5.36-27-5.36-27ZM84.82 13h7.75v27H81.82c5.75-7.8 3-27 3-27Z" fill="#fff" fill-opacity=".4"/></g></g><g transform="translate(25 44)"><mask id="faceSquare01-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><rect width="130" height="120" rx="18" fill="#fff"/></mask><g mask="url(#faceSquare01-a)"><path d="M-2-2h134v124H-2V-2Z" fill="#fdd835"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><rect x="4" y="4" width="68" height="24" rx="5" fill="#000" fill-opacity=".2"/><rect x="8" y="8" width="60" height="16" rx="2" fill="#000" fill-opacity=".8"/><path d="M9 17h11l2-4 3 7 4-8 2 9 3-11 3 10 3-3h15l3-4 2 7 3-3h4" stroke="#4EFAC9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></g><g transform="translate(38 76)"><g fill="#fff"><circle cx="24" cy="30" r="6"/><circle cx="80" cy="30" r="6"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="90" ry="90" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#06b6d4" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><mask id="sidesSquareAssymetric-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="10" y="8" width="165" height="60"><g fill="#fff"><rect x="10" y="31" width="36" height="30" rx="4"/><rect x="20" y="15" width="26" height="30" rx="4"/><rect x="139" y="23" width="36" height="30" rx="4"/><rect x="134" y="8" width="36" height="60" rx="4"/></g></mask><g mask="url(#sidesSquareAssymetric-a)"><path d="M0 0h180v76H0V0Z" fill="#1e88e5"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#000" fill-opacity=".1" d="M0 47h180v29H0z"/><circle cx="161" cy="20" r="5" fill="#fff" fill-opacity=".6"/><circle cx="161" cy="36" r="5" fill="#fff" fill-opacity=".6"/></g></g><g transform="translate(41)"><mask id="topAntenna-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="38" y="5" width="24" height="47"><path d="M38 38c0-1.1.9-2 2-2h20a2 2 0 0 1 2 2v14H38V38ZM48 5h4v31h-4z" fill="#fff"/></mask><g mask="url(#topAntenna-a)"><path d="M0 0h100v52H0V0Z" fill="#1e88e5"/><path d="M0 3h100v52H0V3Z" fill="#fff" fill-opacity=".3"/><path fill="#fff" fill-opacity=".2" d="M38 36h24v16H38z"/></g><circle cx="50" cy="8" r="8" fill="#FFE65C"/><circle cx="53" cy="5" r="3" fill="#fff"/></g><g transform="translate(25 44)"><mask id="faceRound01-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><path fill-rule="evenodd" clip-rule="evenodd" d="M66 0c58.35 0 64 40.69 64 78 0 33.31-25.47 42-64 42-37.46 0-66-8.69-66-42C0 40.69 7.65 0 66 0Z" fill="#fff"/></mask><g mask="url(#faceRound01-a)"><path d="M-4-2h138v124H-4V-2Z" fill="#1e88e5"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><g fill="#000" fill-opacity=".6"><rect x="28" y="10" width="6" height="14" rx="2"/><rect x="14" y="10" width="6" height="14" rx="2"/><rect x="42" y="10" width="6" height="14" rx="2"/><rect x="56" y="10" width="6" height="14" rx="2"/></g></g><g transform="translate(38 76)"><rect x="7" y="16" width="91" height="16" rx="4" fill="#000" fill-opacity=".8"/><mask id="eyesRobocop-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="7" y="16" width="91" height="16"><rect x="7" y="16" width="91" height="16" rx="4" fill="#fff"/></mask><g mask="url(#eyesRobocop-a)" fill-rule="evenodd" clip-rule="evenodd" fill="#fff" fill-opacity=".8"><path d="M76 7h18L82 37H64L76 7ZM52 7h9L49 37h-9L52 7Z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="focizGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="50%" stop-color="#4f46e5"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<filter id="focizShadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4f46e5" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Achtergrond met afgeronde hoeken (superellipse / squircl) -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#focizGrad)" filter="url(#focizShadow)"/>
|
||||
<!-- Subtiele binnenrand highlight -->
|
||||
<rect x="3" y="3" width="58" height="58" rx="15" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- Karakteristieke Fociz 'F' -->
|
||||
<path d="M22 17 H43 C44.6569 17 46 18.3431 46 20 C46 21.6569 44.6569 23 43 23 H28 V29 H39 C40.6569 29 42 30.3431 42 32 C42 33.6569 40.6569 35 39 35 H28 V44 C28 45.6569 26.6569 47 25 47 C23.3431 47 22 45.6569 22 44 V17 Z" fill="#ffffff"/>
|
||||
<!-- Subtiele focus stip / accent -->
|
||||
<circle cx="43" cy="43" r="3.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="focizGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="50%" stop-color="#4f46e5"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<filter id="focizShadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4f46e5" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Achtergrond met afgeronde hoeken (superellipse / squircl) -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#focizGrad)" filter="url(#focizShadow)"/>
|
||||
<!-- Subtiele binnenrand highlight -->
|
||||
<rect x="3" y="3" width="58" height="58" rx="15" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- Karakteristieke Fociz 'F' -->
|
||||
<path d="M22 17 H43 C44.6569 17 46 18.3431 46 20 C46 21.6569 44.6569 23 43 23 H28 V29 H39 C40.6569 29 42 30.3431 42 32 C42 33.6569 40.6569 35 39 35 H28 V44 C28 45.6569 26.6569 47 25 47 C23.3431 47 22 45.6569 22 44 V17 Z" fill="#ffffff"/>
|
||||
<!-- Subtiele focus stip / accent -->
|
||||
<circle cx="43" cy="43" r="3.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -253,7 +253,14 @@
|
||||
if (isFetchingEvents) return;
|
||||
isFetchingEvents = true;
|
||||
try {
|
||||
const res = await fetch('/api/notifications.php?action=get_events', { cache: 'no-store' });
|
||||
const res = await fetch('/api/notifications.php?action=get_events', {
|
||||
cache: 'no-store',
|
||||
headers: { 'X-Background-Poll': '1' }
|
||||
});
|
||||
if (res.status === 401) {
|
||||
console.warn('Notificatiesessie niet geauthenticeerd.');
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.success) {
|
||||
@@ -404,13 +411,129 @@
|
||||
}
|
||||
};
|
||||
|
||||
// 7b. Real-time Live Sync (Mail, Jira & Agenda)
|
||||
let lastKnownMailId = localStorage.getItem('fociz_last_mail_id') || '';
|
||||
let lastKnownJiraKey = localStorage.getItem('fociz_last_jira_key') || '';
|
||||
let lastCalendarHash = '';
|
||||
let isLiveSyncRunning = false;
|
||||
let isInitialLiveSync = true;
|
||||
|
||||
async function checkLiveSync() {
|
||||
if (isLiveSyncRunning) return;
|
||||
isLiveSyncRunning = true;
|
||||
try {
|
||||
const url = `/api/live_sync.php?last_mail_id=${encodeURIComponent(lastKnownMailId)}&last_jira_key=${encodeURIComponent(lastKnownJiraKey)}&calendar_hash=${encodeURIComponent(lastCalendarHash)}`;
|
||||
const res = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: { 'X-Background-Poll': '1' }
|
||||
});
|
||||
if (res.status === 401) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.success) {
|
||||
// Update mail badge in navigatie (desktop & mobiel)
|
||||
const mailBadge = document.getElementById('navMailBadge');
|
||||
const mailBadgeMobile = document.getElementById('navMailBadgeMobile');
|
||||
if (typeof data.mail?.unread_count !== 'undefined') {
|
||||
const count = parseInt(data.mail.unread_count, 10) || 0;
|
||||
const badgeText = count > 99 ? '99+' : count;
|
||||
[mailBadge, mailBadgeMobile].forEach(el => {
|
||||
if (!el) return;
|
||||
if (count > 0) {
|
||||
el.innerText = badgeText;
|
||||
el.classList.remove('hidden');
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// E-mail melding
|
||||
if (!isInitialLiveSync && data.mail?.has_new && data.mail.new_mail) {
|
||||
const m = data.mail.new_mail;
|
||||
sendBrowserNotification(
|
||||
`✉️ Nieuwe mail: ${m.from_name}`,
|
||||
`${m.subject} • ${m.received_human || 'Zojuist'}`,
|
||||
m.web_link || '/mail.php',
|
||||
`fociz-mail-${m.id}`
|
||||
);
|
||||
}
|
||||
|
||||
// Jira ticket melding
|
||||
if (!isInitialLiveSync && data.jira?.has_new && data.jira.new_issue) {
|
||||
const j = data.jira.new_issue;
|
||||
sendBrowserNotification(
|
||||
`⚡ Nieuw Jira ticket: [${j.key}]`,
|
||||
`${j.summary} (${j.priority})`,
|
||||
j.url || '/jira.php',
|
||||
`fociz-jira-${j.key}`
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatch custom event voor actieve pagina's
|
||||
if (data.mail?.has_new || (data.mail?.latest_id && data.mail.latest_id !== lastKnownMailId)) {
|
||||
window.dispatchEvent(new CustomEvent('fociz:mail-updated', { detail: data.mail }));
|
||||
}
|
||||
if (data.jira?.has_new || (data.jira?.newest_key && data.jira.newest_key !== lastKnownJiraKey)) {
|
||||
window.dispatchEvent(new CustomEvent('fociz:jira-updated', { detail: data.jira }));
|
||||
}
|
||||
if (data.calendar?.has_changes) {
|
||||
window.dispatchEvent(new CustomEvent('fociz:calendar-updated', { detail: data.calendar }));
|
||||
}
|
||||
|
||||
if (data.mail?.latest_id) {
|
||||
lastKnownMailId = data.mail.latest_id;
|
||||
localStorage.setItem('fociz_last_mail_id', lastKnownMailId);
|
||||
}
|
||||
if (data.jira?.newest_key) {
|
||||
lastKnownJiraKey = data.jira.newest_key;
|
||||
localStorage.setItem('fociz_last_jira_key', lastKnownJiraKey);
|
||||
}
|
||||
if (data.calendar?.hash) {
|
||||
lastCalendarHash = data.calendar.hash;
|
||||
}
|
||||
|
||||
isInitialLiveSync = false;
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
// Stil negeren bij netwerkvertraging
|
||||
} finally {
|
||||
isLiveSyncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Initialisatie
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
refreshEvents();
|
||||
// Polling interval elke 60 seconden
|
||||
checkLiveSync();
|
||||
|
||||
// Polling interval elke 60 seconden voor agenda evaluatie
|
||||
setInterval(refreshEvents, 60000);
|
||||
// Evaluatie interval elke 15 seconden
|
||||
// Evaluatie interval elke 15 seconden voor agenda popups
|
||||
setInterval(evaluateEvents, 15000);
|
||||
// Live sync interval elke 30 seconden voor mail, Jira & calendar updates
|
||||
setInterval(checkLiveSync, 30000);
|
||||
|
||||
// Intelligente throttle voor window focus en tabwisseling (max 1x per 20s)
|
||||
let lastFocusCheck = Date.now();
|
||||
const throttledSync = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastFocusCheck > 20000) {
|
||||
lastFocusCheck = now;
|
||||
checkLiveSync();
|
||||
refreshEvents();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', throttledSync);
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
throttledSync();
|
||||
}
|
||||
});
|
||||
|
||||
updateDotStatus();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Fociz Session Guard (Release v1.6.1)
|
||||
* Realtime beveiligingsbeheer voor sessie-inactiviteit conform NIST SP 800-63B / ISO 27001
|
||||
* - Waarschuwt de gebruiker 2 minuten voor het verlopen met aftelklok
|
||||
* - Throttled keepalive pings tijdens actieve interactie
|
||||
* - Synchronisatie over meerdere open browsertabbladen via localStorage
|
||||
* - Beschermd tegen oneindige herlaad- en redirect-loops
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Script configuratie uitlezen
|
||||
var scriptTag = document.currentScript || document.querySelector('script[src*="session-guard.js"]');
|
||||
var rawTimeout = scriptTag ? parseInt(scriptTag.getAttribute('data-timeout'), 10) : 1800;
|
||||
var idleTimeout = (!isNaN(rawTimeout) && rawTimeout > 0) ? rawTimeout : 1800;
|
||||
|
||||
// Waarschuwing 120 seconden voor afloop (of de helft indien timeout kort is)
|
||||
var warningSeconds = idleTimeout > 240 ? 120 : Math.max(10, Math.floor(idleTimeout / 2));
|
||||
|
||||
var now = Date.now();
|
||||
var lastActivity = now;
|
||||
var lastPingTime = now;
|
||||
var isWarningShowing = false;
|
||||
var keepaliveInFlight = false;
|
||||
var hasRedirected = false;
|
||||
|
||||
// Synchronisatie met andere tabbladen:
|
||||
// Alleen een timestamp overnemen als deze GELDIG is en NIET al verlopen is!
|
||||
// Aangezien DEZE pagina zojuist succesvol geladen is, is de sessie NU actief.
|
||||
var storedActivity = localStorage.getItem('fociz_session_last_activity');
|
||||
if (storedActivity) {
|
||||
var parsed = parseInt(storedActivity, 10);
|
||||
if (!isNaN(parsed) && parsed > 0 && parsed <= now && (now - parsed) < (idleTimeout * 1000)) {
|
||||
lastActivity = Math.max(now, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct opslaan zodat alle tabbladen weten dat er zojuist activiteit was
|
||||
try {
|
||||
localStorage.setItem('fociz_session_last_activity', lastActivity.toString());
|
||||
localStorage.removeItem('fociz_session_logged_out');
|
||||
} catch (e) {}
|
||||
|
||||
// Veilige eenmalige redirect helper om redirect loops te voorkomen
|
||||
function redirectToExpired() {
|
||||
if (hasRedirected) return;
|
||||
hasRedirected = true;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_logged_out', Date.now().toString());
|
||||
} catch (e) {}
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
|
||||
// Modal DOM elementen
|
||||
var modalEl = null;
|
||||
var countdownEl = null;
|
||||
var keepaliveBtn = null;
|
||||
var logoutBtn = null;
|
||||
|
||||
function initDom() {
|
||||
modalEl = document.getElementById('focizSessionModal');
|
||||
countdownEl = document.getElementById('focizSessionCountdown');
|
||||
keepaliveBtn = document.getElementById('focizSessionKeepaliveBtn');
|
||||
logoutBtn = document.getElementById('focizSessionLogoutBtn');
|
||||
|
||||
if (keepaliveBtn) {
|
||||
keepaliveBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
sendKeepalive(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
performLogout();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Luister naar tabblad synchronisatie
|
||||
window.addEventListener('storage', function (e) {
|
||||
if (e.key === 'fociz_session_last_activity') {
|
||||
var updated = parseInt(e.newValue, 10);
|
||||
if (!isNaN(updated) && updated > lastActivity) {
|
||||
lastActivity = updated;
|
||||
if (isWarningShowing) {
|
||||
hideModal();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'fociz_session_logged_out') {
|
||||
if (!hasRedirected) {
|
||||
var loggedOutTime = parseInt(e.newValue, 10);
|
||||
if (!isNaN(loggedOutTime) && Math.abs(Date.now() - loggedOutTime) < 10000) {
|
||||
hasRedirected = true;
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Formatteer seconden naar mm:ss
|
||||
function formatTime(seconds) {
|
||||
var m = Math.floor(seconds / 60);
|
||||
var s = seconds % 60;
|
||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
// Toon modal
|
||||
function showModal(remainingSeconds) {
|
||||
if (!modalEl) initDom();
|
||||
if (!modalEl) return;
|
||||
|
||||
isWarningShowing = true;
|
||||
modalEl.classList.remove('hidden');
|
||||
modalEl.classList.add('flex');
|
||||
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = formatTime(remainingSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
// Verberg modal
|
||||
function hideModal() {
|
||||
if (!modalEl) return;
|
||||
isWarningShowing = false;
|
||||
modalEl.classList.add('hidden');
|
||||
modalEl.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Verzend keepalive naar de backend
|
||||
function sendKeepalive(manualClick) {
|
||||
if (hasRedirected || keepaliveInFlight) return;
|
||||
keepaliveInFlight = true;
|
||||
|
||||
if (manualClick && keepaliveBtn) {
|
||||
keepaliveBtn.disabled = true;
|
||||
keepaliveBtn.textContent = 'Verlengen...';
|
||||
}
|
||||
|
||||
fetch('/api/session.php?action=keepalive', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.status === 401) {
|
||||
// Reeds verlopen op de server
|
||||
redirectToExpired();
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
keepaliveInFlight = false;
|
||||
if (manualClick && keepaliveBtn) {
|
||||
keepaliveBtn.disabled = false;
|
||||
keepaliveBtn.textContent = 'Blijf Ingelogd';
|
||||
}
|
||||
|
||||
if (data && data.success) {
|
||||
var currentNow = Date.now();
|
||||
lastActivity = currentNow;
|
||||
lastPingTime = currentNow;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_last_activity', currentNow.toString());
|
||||
} catch (e) {}
|
||||
hideModal();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
keepaliveInFlight = false;
|
||||
if (manualClick && keepaliveBtn) {
|
||||
keepaliveBtn.disabled = false;
|
||||
keepaliveBtn.textContent = 'Blijf Ingelogd';
|
||||
}
|
||||
console.error('Session keepalive error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Direct uitloggen
|
||||
function performLogout() {
|
||||
if (hasRedirected) return;
|
||||
hasRedirected = true;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_logged_out', Date.now().toString());
|
||||
} catch (e) {}
|
||||
fetch('/api/session.php?action=logout', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).finally(function () {
|
||||
window.location.href = '/auth/logout.php';
|
||||
});
|
||||
}
|
||||
|
||||
// Registreer gebruikersactiviteit (throttled)
|
||||
function registerUserActivity() {
|
||||
if (hasRedirected) return;
|
||||
var currentNow = Date.now();
|
||||
|
||||
// Controleer of de sessie al verlopen is (bijv. laptop uit slaapstand)
|
||||
var elapsedSec = Math.floor((currentNow - lastActivity) / 1000);
|
||||
if (elapsedSec >= idleTimeout) {
|
||||
redirectToExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
// Als de waarschuwingsmodal actief is, vereisen we expliciete klik op 'Blijf Ingelogd'
|
||||
if (isWarningShowing) return;
|
||||
|
||||
// Lokale activiteit bijwerken (maximaal eens per 3 seconden wegschrijven)
|
||||
if (currentNow - lastActivity > 3000) {
|
||||
lastActivity = currentNow;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_last_activity', currentNow.toString());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Server keepalive ping (maximaal eens per 60 seconden)
|
||||
var minPingInterval = Math.min(60000, Math.max(15000, (idleTimeout * 1000) / 4));
|
||||
if (currentNow - lastPingTime > minPingInterval) {
|
||||
lastPingTime = currentNow;
|
||||
sendKeepalive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct controleren bij terugkeer naar tabblad of ontwaken uit slaapstand
|
||||
function checkIdleOnWakeup() {
|
||||
if (hasRedirected) return;
|
||||
var currentNow = Date.now();
|
||||
var stored = localStorage.getItem('fociz_session_last_activity');
|
||||
if (stored) {
|
||||
var parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed > 0 && parsed <= currentNow) {
|
||||
lastActivity = Math.max(lastActivity, parsed);
|
||||
}
|
||||
}
|
||||
var elapsedSec = Math.floor((currentNow - lastActivity) / 1000);
|
||||
if (elapsedSec >= idleTimeout) {
|
||||
redirectToExpired();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', checkIdleOnWakeup);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkIdleOnWakeup();
|
||||
}
|
||||
});
|
||||
|
||||
// Event listeners voor menselijke interactie
|
||||
var events = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart'];
|
||||
events.forEach(function (evt) {
|
||||
window.addEventListener(evt, registerUserActivity, { passive: true });
|
||||
});
|
||||
|
||||
// Hoofdtimer interval (elke seconde)
|
||||
function tick() {
|
||||
if (hasRedirected) return;
|
||||
var currentNow = Date.now();
|
||||
var elapsedSec = Math.floor((currentNow - lastActivity) / 1000);
|
||||
var remainingSec = idleTimeout - elapsedSec;
|
||||
|
||||
if (remainingSec <= 0) {
|
||||
redirectToExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingSec <= warningSeconds) {
|
||||
// Binnen waarschuwingsvenster
|
||||
showModal(remainingSec);
|
||||
} else if (isWarningShowing) {
|
||||
// Waarschuwing niet meer van toepassing (bijv. verlengd vanuit ander tabblad)
|
||||
hideModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Starten zodra DOM gereed is
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initDom();
|
||||
setInterval(tick, 1000);
|
||||
});
|
||||
} else {
|
||||
initDom();
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
})();
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -27,7 +27,9 @@ if (empty($_SESSION['user_id']) && $stateUserId) {
|
||||
$_SESSION['user_email'] = $recoveredUser['email'];
|
||||
$_SESSION['user_role'] = $recoveredUser['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
issue_remember_token((int)$recoveredUser['id']);
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
clear_remember_cookie();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Foutafhandeling
|
||||
@@ -50,6 +52,13 @@ if (!empty($_GET['error'])) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3b. Afhandeling van beheerderstoestemming (Admin Consent)
|
||||
// Microsoft stuurt bij het /adminconsent endpoint geen state mee, enkel ?admin_consent=True&tenant=...
|
||||
if (!empty($_GET['admin_consent'])) {
|
||||
header('Location: /admin.php?success=' . urlencode('Beheerdersgoedkeuring voor uw organisatie is succesvol verleend! U en uw collega\'s kunnen nu direct hun Outlook agenda en e-mail koppelen via Fociz.'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. Valideer de state (via HMAC handtekening of fallback sessiestate)
|
||||
$expectedState = $_SESSION['ms_oauth_state'] ?? '';
|
||||
unset($_SESSION['ms_oauth_state'], $_SESSION['ms_oauth_user_id']);
|
||||
@@ -59,12 +68,6 @@ if (!$stateUserId && (empty($receivedState) || empty($expectedState) || !hash_eq
|
||||
exit;
|
||||
}
|
||||
|
||||
// Afhandeling van beheerderstoestemming (Admin Consent)
|
||||
if (!empty($_GET['admin_consent'])) {
|
||||
header('Location: /admin.php?success=' . urlencode('Beheerdersgoedkeuring voor uw organisatie is succesvol verleend! U en uw collega\'s kunnen nu direct hun Outlook agenda koppelen via Mijn Profiel.'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Autorisatiecode controleren
|
||||
$code = $_GET['code'] ?? '';
|
||||
if (empty($code)) {
|
||||
@@ -134,9 +137,21 @@ $saveStmt = $db->prepare("
|
||||
settings = VALUES(settings),
|
||||
last_sync_at = NOW()
|
||||
");
|
||||
$saveStmt->execute([$user['id'], $accessToken, $refreshToken, json_encode($newSettings)]);
|
||||
$saveStmt->execute([
|
||||
$user['id'],
|
||||
fociz_encrypt($accessToken),
|
||||
fociz_encrypt($refreshToken),
|
||||
json_encode($newSettings)
|
||||
]);
|
||||
|
||||
// 8. Succesvol terugkeren naar de Persoonlijke Profiel Hub
|
||||
header('Location: /profile.php?success=' . urlencode("Microsoft Outlook succesvol gekoppeld als {$accountEmail}!"));
|
||||
// 8. Succesvol terugkeren naar de gewenste pagina of het Persoonlijk Profiel
|
||||
$returnTo = $_SESSION['ms_oauth_return_to'] ?? '/profile.php';
|
||||
unset($_SESSION['ms_oauth_return_to']);
|
||||
|
||||
$redirectUrl = ($returnTo === '/mail.php')
|
||||
? '/mail.php?success=' . urlencode("Microsoft Outlook 365 e-mail & agenda succesvol gekoppeld als {$accountEmail}!")
|
||||
: '/profile.php?success=' . urlencode("Microsoft Outlook succesvol gekoppeld als {$accountEmail}!");
|
||||
|
||||
header('Location: ' . $redirectUrl);
|
||||
exit;
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<?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') {
|
||||
$clientIp = fociz_get_client_ip();
|
||||
$ipRateKey = "forgot_ip_{$clientIp}";
|
||||
$ipLimit = fociz_check_rate_limit($ipRateKey, 5, 900);
|
||||
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$emailRateKey = "forgot_email_{$email}";
|
||||
$emailLimit = fociz_check_rate_limit($emailRateKey, 3, 900);
|
||||
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$error = "Sessie verlopen of beveiligingstoken ongeldig. Ververs de pagina en probeer opnieuw.";
|
||||
} elseif (!$ipLimit['allowed'] || !$emailLimit['allowed']) {
|
||||
$retryAfter = max($ipLimit['retry_after'], $emailLimit['retry_after']);
|
||||
$error = "Te veel herstelaanvragen. Probeer het over " . ceil($retryAfter / 60) . " minuten opnieuw.";
|
||||
} elseif (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$error = "Vul een geldig e-mailadres in.";
|
||||
} else {
|
||||
fociz_hit_rate_limit($ipRateKey, 900);
|
||||
fociz_hit_rate_limit($emailRateKey, 900);
|
||||
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']) {
|
||||
$errMsg = $mailResult['error'] ?? 'Mailserver fout';
|
||||
error_log("Fout bij verzenden wachtwoordherstel mail naar {$email}: {$errMsg}");
|
||||
$error = "De herstelmail kon niet worden verzonden via de mailserver: " . htmlspecialchars($errMsg);
|
||||
} else {
|
||||
$submitted = true;
|
||||
}
|
||||
} else {
|
||||
// In development omgeving geven we directe diagnostische feedback
|
||||
if (defined('APP_ENV') && APP_ENV === 'development') {
|
||||
$error = "Er is geen Fociz account gevonden met het e-mailadres '{$email}'. Controleer of dit e-mailadres correct is ingevoerd.";
|
||||
} else {
|
||||
// Anti-enumeratie op productie: toon altijd succes
|
||||
$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>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<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">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<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>
|
||||
|
||||
@@ -14,11 +14,11 @@ if (!$invite) {
|
||||
<!DOCTYPE html>
|
||||
<html lang='nl' class='dark'>
|
||||
<head><script src='https://cdn.tailwindcss.com'></script></head>
|
||||
<body class='bg-[#090d16] text-white min-h-screen flex items-center justify-center p-4'>
|
||||
<div class='max-w-md bg-slate-900 border border-slate-800 rounded-2xl p-8 text-center'>
|
||||
<h2 class='text-xl font-bold text-red-400 mb-2'>Ongeldige Uitnodiging</h2>
|
||||
<body class='bg-[#0c0f17] text-white min-h-screen flex items-center justify-center p-4'>
|
||||
<div class='max-w-md bg-[#111622] border border-slate-800 rounded-2xl p-8 text-center shadow-xs'>
|
||||
<h2 class='text-xl font-bold text-rose-400 mb-2'>Ongeldige Uitnodiging</h2>
|
||||
<p class='text-sm text-slate-400 mb-6'>Deze uitnodigingslink is ongeldig, verlopen of al eerder gebruikt.</p>
|
||||
<a href='/auth/login.php' class='px-4 py-2 bg-emerald-500 text-slate-950 font-bold rounded-xl text-xs'>Naar Inloggen</a>
|
||||
<a href='/auth/login.php' class='px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl text-xs transition shadow-xs'>Naar Inloggen</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -39,8 +39,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
if (empty($name) || empty($password) || empty($totpCode)) {
|
||||
$error = "Alle velden (inclusief 2FA verificatiecode) zijn verplicht.";
|
||||
} elseif (strlen($password) < 8) {
|
||||
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn.";
|
||||
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
|
||||
$error = $pwErr;
|
||||
} elseif ($password !== $confirmPassword) {
|
||||
$error = "Wachtwoorden komen niet overeen.";
|
||||
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
|
||||
@@ -75,13 +75,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$db->commit();
|
||||
|
||||
// Start sessie
|
||||
fociz_log_audit($newUserId, 'USER_REGISTERED_VIA_INVITE', 'user', $newUserId, ['email' => $invite['email'], 'role' => $invite['role']], 'SUCCESS', $invite['email']);
|
||||
|
||||
// Start dagsessie met verse sessie ID (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6)
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
$_SESSION['user_id'] = $newUserId;
|
||||
$_SESSION['user_name'] = $name;
|
||||
$_SESSION['user_email'] = $invite['email'];
|
||||
$_SESSION['user_role'] = $invite['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
issue_remember_token($newUserId);
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
clear_remember_cookie();
|
||||
issue_day_session_token($newUserId);
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session($newUserId, $newSessionId, $maxIdleSeconds);
|
||||
unset($_SESSION['invite_totp_secret']);
|
||||
|
||||
header('Location: /planner.php');
|
||||
@@ -103,27 +119,58 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage -->
|
||||
<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>
|
||||
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
|
||||
<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-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
|
||||
<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-lg bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
|
||||
<div class="w-full max-w-lg bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
|
||||
<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="shield-check" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white">Welkom bij Fociz</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Uitgenodigd door <strong class="text-slate-200"><?= htmlspecialchars($invite['inviter_name']) ?></strong> voor <span class="text-emerald-400 font-mono text-xs bg-emerald-500/10 px-2 py-0.5 rounded"><?= htmlspecialchars($invite['email']) ?></span>
|
||||
<h1 class="text-2xl font-black text-slate-900 dark:text-white">Welkom bij Fociz</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
Uitgenodigd door <strong class="text-slate-900 dark:text-slate-200"><?= htmlspecialchars($invite['inviter_name']) ?></strong> voor <span class="text-indigo-600 dark:text-indigo-400 font-mono text-xs bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 px-2 py-0.5 rounded-lg"><?= htmlspecialchars($invite['email']) ?></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
|
||||
<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>
|
||||
@@ -133,55 +180,67 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Uw naam</label>
|
||||
<input type="text" name="name" required placeholder="Bijv. Sarah de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Uw naam</label>
|
||||
<input type="text" name="name" required placeholder="Bijv. Sarah de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" 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 class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 12 tekens)</label>
|
||||
<input type="password" name="password" required minlength="12" 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>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required minlength="12" 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>
|
||||
|
||||
<!-- 2FA Verplichting -->
|
||||
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider text-emerald-400">
|
||||
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
|
||||
Verplichte Tweestapsverificatie (2FA)
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
Scan deze QR-code in uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
|
||||
</p>
|
||||
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xl">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-40 h-40 mx-auto">
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
|
||||
<div id="qrcode" class="w-40 h-40 mx-auto flex items-center justify-center"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono">
|
||||
Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong>
|
||||
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono">
|
||||
Sleutel: <strong class="text-indigo-600 dark:text-indigo-400"><?= htmlspecialchars($secret) ?></strong>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1.5">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Voer de 6-cijferige code uit uw app in:
|
||||
</label>
|
||||
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
|
||||
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-2 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<button type="submit" class="w-full mt-6 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">
|
||||
<span>Account Activeren & Inloggen</span>
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/qrcode.min.js"></script>
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
const qrContainer = document.getElementById('qrcode');
|
||||
if (qrContainer) {
|
||||
new QRCode(qrContainer, {
|
||||
text: <?= json_encode($otpAuthUrl) ?>,
|
||||
width: 160,
|
||||
height: 160,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,6 +12,18 @@ if (!has_admin_user()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Als de sessie verlopen, ingetrokken of gewijzigd is: beëindig deze direct en wis cookies
|
||||
if (isset($_GET['expired']) || isset($_GET['revoked']) || isset($_GET['ip_changed'])) {
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
fociz_terminate_session(session_id());
|
||||
}
|
||||
clear_day_session_cookie();
|
||||
$_SESSION = [];
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Als al ingelogd (via actieve sessie of persistente cookie), ga direct naar planner
|
||||
$currentUser = get_logged_in_user();
|
||||
if ($currentUser) {
|
||||
@@ -20,25 +32,78 @@ if ($currentUser) {
|
||||
}
|
||||
|
||||
$error = null;
|
||||
$infoMessage = null;
|
||||
if (isset($_GET['expired'])) {
|
||||
$infoMessage = "Uw sessie is beeïndigd";
|
||||
}
|
||||
if (isset($_GET['revoked'])) {
|
||||
$infoMessage = "Uw sessie is op afstand beëindigd of ingetrokken. Log opnieuw in om verder te gaan.";
|
||||
}
|
||||
if (isset($_GET['ip_changed'])) {
|
||||
$infoMessage = "U bent ingelogd vanaf een andere netwerklocatie. Uit veiligheidsoverwegingen dient u opnieuw in te loggen met 2FA.";
|
||||
}
|
||||
if (isset($_GET['ip_verified'])) {
|
||||
$infoMessage = "Nieuwe inloglocatie succesvol geverifieerd! U kunt nu veilig inloggen.";
|
||||
}
|
||||
$step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
|
||||
|
||||
// Terug naar stap 1
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
|
||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email']);
|
||||
unset(
|
||||
$_SESSION['pending_2fa_user_id'],
|
||||
$_SESSION['pending_2fa_user_email'],
|
||||
$_SESSION['pending_2fa_user_name'],
|
||||
$_SESSION['pending_new_ip'],
|
||||
$_SESSION['pending_new_ip_mail_sent']
|
||||
);
|
||||
header('Location: /auth/login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verificatiemail opnieuw verzenden bij nieuw IP
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'resend_ip_mail' && !empty($_SESSION['pending_2fa_user_id']) && !empty($_SESSION['pending_new_ip'])) {
|
||||
$resendUserId = (int)$_SESSION['pending_2fa_user_id'];
|
||||
$resendRateKey = "resend_ip_mail_{$resendUserId}";
|
||||
$resendLimit = fociz_check_rate_limit($resendRateKey, 2, 60);
|
||||
|
||||
if (!$resendLimit['allowed']) {
|
||||
$error = "Wacht even voordat u opnieuw een e-mail aanvraagt (over " . ceil($resendLimit['retry_after']) . " seconden).";
|
||||
} else {
|
||||
fociz_hit_rate_limit($resendRateKey, 60);
|
||||
$resendEmail = $_SESSION['pending_2fa_user_email'] ?? '';
|
||||
$resendName = $_SESSION['pending_2fa_user_name'] ?? '';
|
||||
$resendIp = $_SESSION['pending_new_ip'];
|
||||
$sent = fociz_send_new_ip_verification($resendUserId, $resendEmail, $resendName, $resendIp);
|
||||
if ($sent) {
|
||||
$infoMessage = "Er is een nieuwe verificatielink verstuurd naar " . htmlspecialchars($resendEmail) . ".";
|
||||
$_SESSION['pending_new_ip_mail_sent'] = true;
|
||||
} else {
|
||||
$error = "De mailserver kon de e-mail niet direct afleveren. U kunt echter direct hieronder inloggen met uw 2FA Authenticator code om deze locatie te autoriseren.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST Verwerking
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$db = get_db_connection();
|
||||
$clientIp = fociz_get_client_ip();
|
||||
$ipRateKey = "login_ip_{$clientIp}";
|
||||
$ipLimit = fociz_check_rate_limit($ipRateKey, 10, 900);
|
||||
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$error = "Sessie verlopen of beveiligingstoken ongeldig. Ververs de pagina en probeer opnieuw.";
|
||||
} elseif (!$ipLimit['allowed']) {
|
||||
$error = "Te veel inlogpogingen vanaf dit netwerkadres. Probeer het over " . ceil($ipLimit['retry_after'] / 60) . " minuten opnieuw.";
|
||||
} elseif ($step === 1) {
|
||||
// STAP 1: Wachtwoord verificatie
|
||||
if ($step === 1) {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$password = $_POST['password'] ?? '';
|
||||
$emailRateKey = "login_email_{$email}";
|
||||
$emailLimit = fociz_check_rate_limit($emailRateKey, 5, 900);
|
||||
|
||||
if (empty($email) || empty($password)) {
|
||||
if (!$emailLimit['allowed']) {
|
||||
$error = "Dit account is tijdelijk geblokkeerd wegens te veel mislukte pogingen. Probeer het over " . ceil($emailLimit['retry_after'] / 60) . " minuten opnieuw.";
|
||||
} elseif (empty($email) || empty($password)) {
|
||||
$error = "Vul e-mailadres en wachtwoord in.";
|
||||
} else {
|
||||
$stmt = $db->prepare("SELECT id, name, email, password_hash, two_factor_secret, two_factor_enabled FROM users WHERE email = ?");
|
||||
@@ -46,12 +111,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user && password_verify($password, $user['password_hash'])) {
|
||||
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
|
||||
fociz_clear_rate_limit($emailRateKey);
|
||||
// Controleer of de inloglocatie/IP bekend is
|
||||
$isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
|
||||
|
||||
if (!$isIpKnown) {
|
||||
// Stuur verificatie-mail en log audit challenge
|
||||
$mailSent = fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
|
||||
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp, 'mail_sent' => $mailSent], 'PENDING', $user['email']);
|
||||
|
||||
// Laat legitieme gebruiker doorgaan naar Stap 2 (2FA TOTP).
|
||||
// Conform NIST SP 800-63B autoriseert de fysieke Authenticator code de nieuwe locatie.
|
||||
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
||||
$_SESSION['pending_new_ip'] = $clientIp;
|
||||
$_SESSION['pending_new_ip_mail_sent'] = $mailSent;
|
||||
$step = 2;
|
||||
} else {
|
||||
// Bekende locatie: direct door naar 2FA
|
||||
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
||||
unset($_SESSION['pending_new_ip'], $_SESSION['pending_new_ip_mail_sent']);
|
||||
$step = 2;
|
||||
}
|
||||
} else {
|
||||
fociz_hit_rate_limit($ipRateKey, 900);
|
||||
if (!empty($email)) {
|
||||
fociz_hit_rate_limit($emailRateKey, 900);
|
||||
}
|
||||
fociz_log_audit($user ? (int)$user['id'] : null, 'LOGIN_FAILED', 'user', $user ? (int)$user['id'] : null, ['reason' => 'Invalid credentials', 'ip' => $clientIp], 'FAILURE', $email);
|
||||
$error = "Onjuist e-mailadres of wachtwoord.";
|
||||
}
|
||||
}
|
||||
@@ -59,23 +149,38 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// STAP 2: 2FA TOTP Code verificatie
|
||||
elseif ($step === 2) {
|
||||
$userId = $_SESSION['pending_2fa_user_id'] ?? null;
|
||||
$totpCode = trim(str_replace(' ', '', $_POST['totp_code'] ?? ''));
|
||||
$totpCode = trim($_POST['totp_code'] ?? '');
|
||||
|
||||
if (!$userId || empty($totpCode)) {
|
||||
$error = "Vul de 6-cijferige 2FA verificatiecode in.";
|
||||
if (!$userId) {
|
||||
header('Location: /auth/login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$totpRateKey = "login_totp_{$userId}";
|
||||
$totpLimit = fociz_check_rate_limit($totpRateKey, 5, 900);
|
||||
|
||||
if (!$totpLimit['allowed']) {
|
||||
$error = "Te veel ongeldige verificatiecodes. Probeer het over " . ceil($totpLimit['retry_after'] / 60) . " minuten opnieuw.";
|
||||
} elseif (empty($totpCode)) {
|
||||
$error = "Vul de 6-cijferige 2FA code in uit uw authenticator app.";
|
||||
} else {
|
||||
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_secret, backup_codes FROM users WHERE id = ?");
|
||||
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
$isValid = false;
|
||||
|
||||
// 1. Controleer TOTP code
|
||||
if (!empty($user['two_factor_secret']) && TOTP::verifyCode($user['two_factor_secret'], $totpCode)) {
|
||||
$isValid = true;
|
||||
// Controleer reguliere TOTP code
|
||||
if (!empty($user['two_factor_secret'])) {
|
||||
try {
|
||||
$isValid = TOTP::verifyCode($user['two_factor_secret'], $totpCode);
|
||||
} catch (Throwable $t) {
|
||||
error_log("TOTP verificatiefout: " . $t->getMessage());
|
||||
$isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Controleer optioneel backup codes
|
||||
// Fallback: controleer 8-karakter backup codes
|
||||
if (!$isValid && !empty($user['backup_codes'])) {
|
||||
$backupCodes = json_decode($user['backup_codes'], true) ?: [];
|
||||
$codeUpper = strtoupper($totpCode);
|
||||
@@ -90,21 +195,60 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
if ($isValid) {
|
||||
// Volledige login succesvol
|
||||
fociz_clear_rate_limit($totpRateKey);
|
||||
fociz_clear_rate_limit($ipRateKey);
|
||||
|
||||
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
|
||||
// Autoriseert het IP-adres definitief in user_known_ips
|
||||
fociz_register_user_ip((int)$user['id'], $clientIp, true);
|
||||
|
||||
$wasNewIp = !empty($_SESSION['pending_new_ip']);
|
||||
if ($wasNewIp) {
|
||||
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_VERIFIED_VIA_2FA', 'user_known_ips', (int)$user['id'], ['ip' => $clientIp], 'SUCCESS', $user['email']);
|
||||
}
|
||||
|
||||
// Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6):
|
||||
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['user_name'] = $user['name'];
|
||||
$_SESSION['user_email'] = $user['email'];
|
||||
$_SESSION['user_role'] = $user['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
|
||||
// Activeer persistente remember token (30 dagen blijf ingelogd)
|
||||
issue_remember_token((int)$user['id']);
|
||||
// Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag)
|
||||
issue_day_session_token((int)$user['id']);
|
||||
|
||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
|
||||
// Registreer actieve sessie in user_sessions (SOC 2 / ISO 27001)
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
|
||||
|
||||
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA', 'was_new_ip' => $wasNewIp], 'SUCCESS', $user['email']);
|
||||
|
||||
unset(
|
||||
$_SESSION['pending_2fa_user_id'],
|
||||
$_SESSION['pending_2fa_user_email'],
|
||||
$_SESSION['pending_2fa_user_name'],
|
||||
$_SESSION['pending_new_ip'],
|
||||
$_SESSION['pending_new_ip_mail_sent']
|
||||
);
|
||||
|
||||
header('Location: /planner.php');
|
||||
exit;
|
||||
} else {
|
||||
fociz_hit_rate_limit($totpRateKey, 900);
|
||||
fociz_hit_rate_limit($ipRateKey, 900);
|
||||
fociz_log_audit((int)$userId, 'LOGIN_MFA_FAILED', 'user', (int)$userId, ['ip' => $clientIp], 'FAILURE', $_SESSION['pending_2fa_user_email'] ?? null);
|
||||
$error = "Onjuiste 2FA code. Controleer uw authenticator app en probeer opnieuw.";
|
||||
}
|
||||
}
|
||||
@@ -119,28 +263,71 @@ $pageTitle = "Fociz — Inloggen";
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage vóór DOM render -->
|
||||
<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>
|
||||
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
|
||||
<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-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
|
||||
<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-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
|
||||
<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-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
|
||||
<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="shield-check" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-white">Fociz</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
<h1 class="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Fociz</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
Veilig inloggen met tweestapsverificatie (2FA)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($infoMessage): ?>
|
||||
<div class="mb-6 p-4 rounded-2xl bg-sky-500/10 border border-sky-500/30 flex items-start space-x-3 text-sky-800 dark:text-sky-300 text-xs sm:text-sm">
|
||||
<i data-lucide="info" class="w-5 h-5 flex-shrink-0 mt-0.5 text-sky-600 dark:text-sky-400"></i>
|
||||
<span><?= htmlspecialchars($infoMessage) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
|
||||
<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>
|
||||
@@ -149,21 +336,25 @@ $pageTitle = "Fociz — Inloggen";
|
||||
<?php if ($step === 1): ?>
|
||||
<!-- STAP 1: E-mail & Wachtwoord -->
|
||||
<form method="POST" action="/auth/login.php" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">E-mailadres</label>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">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-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium 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">
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 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>
|
||||
|
||||
<button type="submit" class="w-full mt-4 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<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">
|
||||
<span>Verder naar 2FA</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</button>
|
||||
@@ -173,30 +364,51 @@ $pageTitle = "Fociz — Inloggen";
|
||||
|
||||
<!-- STAP 2: 2FA TOTP Code -->
|
||||
<form method="POST" action="/auth/login.php" class="space-y-5">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<?php if (!empty($_SESSION['pending_new_ip'])): ?>
|
||||
<div class="p-3.5 rounded-xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-900 dark:text-amber-200 text-xs space-y-2 text-left">
|
||||
<div class="flex items-center space-x-2 font-bold text-amber-800 dark:text-amber-300">
|
||||
<i data-lucide="shield-alert" class="w-4 h-4 flex-shrink-0"></i>
|
||||
<span>Nieuwe inloglocatie gedetecteerd</span>
|
||||
</div>
|
||||
<p class="text-[11px] leading-relaxed text-amber-700 dark:text-amber-300/90">
|
||||
U logt in vanaf een nieuw netwerkadres (<code class="font-mono bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded text-[10px]"><?= htmlspecialchars($_SESSION['pending_new_ip']) ?></code>).
|
||||
Voer uw 2FA Authenticator code in om uw identiteit te bevestigen en deze locatie automatisch te autoriseren.
|
||||
</p>
|
||||
<div class="pt-1.5 flex items-center justify-between text-[11px] border-t border-amber-200/60 dark:border-amber-800/40 text-amber-700 dark:text-amber-400">
|
||||
<span class="flex items-center space-x-1">
|
||||
<i data-lucide="mail-check" class="w-3.5 h-3.5"></i>
|
||||
<span><?= !empty($_SESSION['pending_new_ip_mail_sent']) ? 'Verificatiemail verzonden' : '2FA verificatie actief' ?></span>
|
||||
</span>
|
||||
<a href="/auth/login.php?action=resend_ip_mail" class="font-bold underline hover:text-amber-900 dark:hover:text-amber-200 cursor-pointer">
|
||||
Mail opnieuw sturen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-emerald-500/10 text-emerald-400 mb-2">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2">
|
||||
<i data-lucide="key" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 font-medium">
|
||||
<p class="text-sm text-slate-800 dark:text-slate-300 font-semibold">
|
||||
Voer uw 6-cijferige verificatiecode in
|
||||
</p>
|
||||
<p class="text-xs text-slate-400 mt-1">
|
||||
Gekoppeld aan <strong class="text-slate-200"><?= htmlspecialchars($_SESSION['pending_2fa_user_email'] ?? '') ?></strong>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Gekoppeld aan <strong class="text-slate-900 dark:text-slate-200"><?= htmlspecialchars($_SESSION['pending_2fa_user_email'] ?? '') ?></strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="text" name="totp_code" required autofocus maxlength="9" placeholder="000 000" class="w-full text-center tracking-[0.4em] font-mono text-2xl bg-slate-800/80 border border-slate-700 rounded-xl py-3 text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
|
||||
<span class="text-[11px] text-slate-500 text-center block mt-1">
|
||||
Tip: U kunt ook een 8-cijferige herstelcode invoeren.
|
||||
</span>
|
||||
<input type="text" name="totp_code" required autofocus maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="000 000" class="w-full text-center tracking-[0.4em] font-mono text-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<a href="/auth/login.php?action=restart" class="w-1/3 text-center bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium py-2.5 px-3 rounded-xl transition text-sm flex items-center justify-center">
|
||||
<a href="/auth/login.php?action=restart" class="w-1/3 text-center bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-medium py-2.5 px-3 rounded-xl transition text-sm flex items-center justify-center cursor-pointer">
|
||||
Terug
|
||||
</a>
|
||||
<button type="submit" class="w-2/3 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<button type="submit" class="w-2/3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<span>Inloggen</span>
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
@@ -205,14 +417,17 @@ $pageTitle = "Fociz — Inloggen";
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-slate-800/80 text-center text-xs text-slate-500">
|
||||
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center text-xs text-slate-500">
|
||||
Toegang tot Fociz is strikt op uitnodiging. Vraag uw beheerder om een uitnodigingslink.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
try {
|
||||
localStorage.removeItem('fociz_session_last_activity');
|
||||
localStorage.removeItem('fociz_session_logged_out');
|
||||
} catch(e) {}
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
start_secure_session();
|
||||
|
||||
// Vernietig persistente login in database
|
||||
// Vernietig persistente login en sessie in database (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
$uid = (int)$_SESSION['user_id'];
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_remember_token_columns($db);
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmt->execute([$_SESSION['user_id']]);
|
||||
$stmt->execute([$uid]);
|
||||
|
||||
fociz_terminate_session(session_id());
|
||||
fociz_log_audit($uid, 'SESSION_TERMINATED_LOGOUT', 'users', $uid, ['ip' => fociz_get_client_ip()], 'SUCCESS', $_SESSION['user_email'] ?? null);
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren bij uitloggen
|
||||
}
|
||||
|
||||
@@ -43,8 +43,17 @@ $state = generate_oauth_state((int)$user['id']);
|
||||
$_SESSION['ms_oauth_state'] = $state;
|
||||
$_SESSION['ms_oauth_user_id'] = (int)$user['id'];
|
||||
|
||||
// Onthoud eventuele gewenste terugkeerpagina (bijv. /mail.php)
|
||||
if (!empty($_GET['return_to'])) {
|
||||
$_SESSION['ms_oauth_return_to'] = trim($_GET['return_to']);
|
||||
} else {
|
||||
unset($_SESSION['ms_oauth_return_to']);
|
||||
}
|
||||
|
||||
$forceConsent = !empty($_GET['reconsent']) || !empty($_GET['force']);
|
||||
|
||||
// Stuur gebruiker door naar Microsoft login
|
||||
$authUrl = microsoft_get_auth_url($clientId, $redirectUri, $tenantId, $state);
|
||||
$authUrl = microsoft_get_auth_url($clientId, $redirectUri, $tenantId, $state, $forceConsent);
|
||||
header("Location: {$authUrl}");
|
||||
exit;
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<?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 (($pwErr = fociz_validate_password($password)) !== null) {
|
||||
$error = $pwErr;
|
||||
} 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;
|
||||
|
||||
fociz_log_audit(null, 'PASSWORD_RESET_COMPLETED', 'user', null, ['email' => $resetRecord['user_email']], 'SUCCESS', $resetRecord['user_email']);
|
||||
|
||||
} 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>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<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 (min. 12 tekens)</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="pwd"
|
||||
required
|
||||
minlength="12"
|
||||
autofocus
|
||||
placeholder="Minimaal 12 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>
|
||||
<p class="text-[10px] text-slate-400 dark:text-slate-500 mt-1">Minimaal 12 tekens (incl. hoofdletter, kleine letter, cijfer en speciaal teken).</p>
|
||||
</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
|
||||
minlength="12"
|
||||
placeholder="Herhaal wachtwoord (min. 12 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"
|
||||
>
|
||||
</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 >= 12) score++;
|
||||
if (/[A-Z]/.test(val) && /[a-z]/.test(val) && /[0-9]/.test(val)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(val) && val.length >= 12) 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>
|
||||
|
||||
@@ -35,8 +35,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$error = "Alle velden (inclusief de 2FA verificatiecode) zijn verplicht.";
|
||||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$error = "Voer een geldig e-mailadres in.";
|
||||
} elseif (strlen($password) < 8) {
|
||||
$error = "Het wachtwoord moet minimaal 8 tekens bevatten.";
|
||||
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
|
||||
$error = $pwErr;
|
||||
} elseif ($password !== $confirmPassword) {
|
||||
$error = "De ingevoerde wachtwoorden komen niet overeen.";
|
||||
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
|
||||
@@ -62,15 +62,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$adminId = (int)$db->lastInsertId();
|
||||
|
||||
// Automatische sessie starten
|
||||
// Automatische dagsessie starten met verse sessie ID (OWASP ASVS v4.0 §3.3.1)
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
$_SESSION['user_id'] = $adminId;
|
||||
$_SESSION['user_name'] = $name;
|
||||
$_SESSION['user_email'] = $email;
|
||||
$_SESSION['user_role'] = 'ADMIN';
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
issue_remember_token($adminId);
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
clear_remember_cookie();
|
||||
issue_day_session_token($adminId);
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session($adminId, $newSessionId, $maxIdleSeconds);
|
||||
unset($_SESSION['setup_totp_secret']);
|
||||
|
||||
fociz_log_audit($adminId, 'INITIAL_ADMIN_SETUP', 'user', $adminId, ['email' => $email, 'name' => $name], 'SUCCESS', $email);
|
||||
|
||||
$success = true;
|
||||
} catch (Exception $e) {
|
||||
$error = "Fout bij aanmaken beheerder in MariaDB: " . $e->getMessage();
|
||||
@@ -88,28 +104,59 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage -->
|
||||
<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>
|
||||
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
|
||||
<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-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
|
||||
<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-xl bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
|
||||
<div class="w-full max-w-xl 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-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
|
||||
<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="shield-check" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-white">Fociz Initialisatie</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
<h1 class="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Fociz Initialisatie</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
Eenmalige configuratie van het hoofdbeheerdersaccount met verplichte 2FA
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
|
||||
<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>
|
||||
@@ -118,26 +165,26 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
<?php if ($success): ?>
|
||||
<!-- Stap 3: Voltooid met backup codes -->
|
||||
<div class="text-center space-y-6">
|
||||
<div class="w-16 h-16 bg-emerald-500/20 text-emerald-400 rounded-full flex items-center justify-center mx-auto border border-emerald-500/40">
|
||||
<div class="w-16 h-16 bg-teal-50 dark:bg-teal-950/40 text-teal-600 dark:text-teal-400 rounded-2xl flex items-center justify-center mx-auto border border-teal-200 dark:border-teal-800/60 shadow-xs">
|
||||
<i data-lucide="check-circle-2" class="w-10 h-10"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white mb-1">Beheerder Geïnstalleerd!</h2>
|
||||
<p class="text-slate-400 text-sm">
|
||||
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-1">Beheerder Geïnstalleerd!</h2>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm">
|
||||
Uw account is aangemaakt en direct beveiligd met 2FA. Bewaar deze eenmalige herstelcodes veilig:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-950/90 border border-slate-800 rounded-xl p-4 text-left">
|
||||
<div class="text-xs font-semibold text-slate-400 mb-2">HERSTELCODES (NOODGEBRUIK):</div>
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-xs text-slate-300">
|
||||
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-2xl p-4 text-left">
|
||||
<div class="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-2">HERSTELCODES (NOODGEBRUIK):</div>
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-xs text-slate-800 dark:text-slate-300">
|
||||
<?php foreach ($backupCodes as $code): ?>
|
||||
<span class="bg-slate-900 px-2.5 py-1.5 rounded border border-slate-800 text-center"><?= htmlspecialchars($code) ?></span>
|
||||
<span class="bg-white dark:bg-[#111622] px-2.5 py-1.5 rounded-xl border border-slate-200 dark:border-slate-800 text-center font-bold"><?= htmlspecialchars($code) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/planner.php" class="inline-flex items-center justify-center w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 space-x-2">
|
||||
<a href="/planner.php" class="inline-flex items-center justify-center w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs space-x-2 cursor-pointer">
|
||||
<span>Start met Fociz Dagplanning</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
@@ -149,54 +196,54 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
<!-- Account Info -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Volledige naam</label>
|
||||
<input type="text" name="name" required placeholder="Bijv. Jan de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Volledige naam</label>
|
||||
<input type="text" name="name" required placeholder="Bijv. Jan de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" 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>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Beheerder E-mailadres</label>
|
||||
<input type="email" name="email" required placeholder="admin@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beheerder E-mailadres</label>
|
||||
<input type="email" name="email" required placeholder="admin@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 class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 12 tekens)</label>
|
||||
<input type="password" name="password" required minlength="12" 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>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required minlength="12" 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>
|
||||
</div>
|
||||
|
||||
<!-- 2FA QR Code & Koppeling -->
|
||||
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center justify-center space-x-1.5 text-emerald-400">
|
||||
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wider flex items-center justify-center space-x-1.5 text-indigo-600 dark:text-indigo-400">
|
||||
<i data-lucide="key" class="w-4 h-4"></i>
|
||||
<span>Tweestapsverificatie (2FA) Koppelen</span>
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 max-w-md mx-auto">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 max-w-md mx-auto">
|
||||
Scan onderstaande QR-code met uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
|
||||
</p>
|
||||
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xl">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-44 h-44 mx-auto">
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
|
||||
<div id="qrcode" class="w-44 h-44 mx-auto flex items-center justify-center"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
|
||||
<span>Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong></span>
|
||||
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
|
||||
<span>Sleutel: <strong class="text-indigo-600 dark:text-indigo-400"><?= htmlspecialchars($secret) ?></strong></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1.5">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Voer de 6-cijferige verificatiecode uit uw app in:
|
||||
</label>
|
||||
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
|
||||
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-2 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<button type="submit" class="w-full mt-6 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">
|
||||
<span>Account & 2FA Bevestigen</span>
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
@@ -205,8 +252,20 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/qrcode.min.js"></script>
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
const qrContainer = document.getElementById('qrcode');
|
||||
if (qrContainer) {
|
||||
new QRCode(qrContainer, {
|
||||
text: <?= json_encode($otpAuthUrl) ?>,
|
||||
width: 176,
|
||||
height: 176,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Nieuwe Inloglocatie / IP Verificatie
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
start_secure_session();
|
||||
|
||||
$token = trim($_GET['token'] ?? '');
|
||||
$db = get_db_connection();
|
||||
|
||||
$error = null;
|
||||
$success = false;
|
||||
$userEmail = '';
|
||||
$ipAddress = '';
|
||||
|
||||
if (empty($token) || !preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||
$error = "Ongeldige of ontbrekende verificatielink.";
|
||||
} else {
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT kip.*, u.email, u.name
|
||||
FROM user_known_ips kip
|
||||
JOIN users u ON kip.user_id = u.id
|
||||
WHERE kip.verify_token = ?
|
||||
");
|
||||
$stmt->execute([$token]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
if (!$record) {
|
||||
$error = "Deze verificatielink is niet gevonden of al gebruikt.";
|
||||
} elseif (!empty($record['verify_token_expires_at']) && strtotime($record['verify_token_expires_at']) < time()) {
|
||||
$error = "Deze verificatielink is verlopen (geldigheidsduur was 30 minuten). Probeer opnieuw in te loggen om een nieuwe link te ontvangen.";
|
||||
} else {
|
||||
// Verifieer het IP-adres
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE user_known_ips
|
||||
SET is_verified = 1, verify_token = NULL, verify_token_expires_at = NULL, last_seen_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$updateStmt->execute([$record['id']]);
|
||||
|
||||
$success = true;
|
||||
$userEmail = $record['email'];
|
||||
$ipAddress = $record['ip_address'];
|
||||
fociz_log_audit((int)$record['user_id'], 'IP_VERIFIED', 'user_known_ips', (int)$record['id'], ['ip' => $ipAddress], 'SUCCESS', $userEmail);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("verify-ip.php error: " . $e->getMessage());
|
||||
$error = "Er is een databasefout opgetreden bij het verifiëren.";
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = "Fociz — Locatie Verificatie";
|
||||
?>
|
||||
<!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>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
indigo: { 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body class="bg-slate-100 dark:bg-[#0c0f17] text-slate-800 dark:text-slate-100 min-h-screen flex items-center justify-center p-4 transition-colors duration-200">
|
||||
|
||||
<div class="w-full max-w-md bg-white dark:bg-[#121622] rounded-2xl border border-slate-200/80 dark:border-slate-800/80 shadow-2xl p-8 space-y-6">
|
||||
|
||||
<!-- Logo & Header -->
|
||||
<div class="text-center space-y-2">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-500/10 border border-indigo-500/20 text-indigo-500 mb-2">
|
||||
<i data-lucide="shield-check" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-slate-900 dark:text-white">Locatie Verificatie</h1>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">Beveiligde inlogtoegang voor Fociz</p>
|
||||
</div>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="p-4 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-800 dark:text-emerald-400 text-sm space-y-2 text-center">
|
||||
<div class="flex items-center justify-center space-x-2 font-bold text-emerald-700 dark:text-emerald-300">
|
||||
<i data-lucide="check-circle-2" class="w-5 h-5"></i>
|
||||
<span>Locatie Succesvol Geverifieerd!</span>
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
Het IP-adres <strong class="font-mono"><?= htmlspecialchars($ipAddress) ?></strong> is nu geautoriseerd voor uw account (<?= htmlspecialchars($userEmail) ?>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<a href="/auth/login.php?ip_verified=1" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<span>Direct Inloggen</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 text-rose-800 dark:text-rose-400 text-sm space-y-2 text-center">
|
||||
<div class="flex items-center justify-center space-x-2 font-bold text-rose-700 dark:text-rose-300">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||
<span>Verificatie Mislukt</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed">
|
||||
<?= htmlspecialchars($error ?? 'Onbekende fout.') ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<a href="/auth/login.php" class="w-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold py-2.5 px-4 rounded-xl transition 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 endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,15 +4,22 @@
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
require_once __DIR__ . '/includes/settings.php';
|
||||
$currentUser = require_auth();
|
||||
$db = get_db_connection();
|
||||
$brainstormCategories = get_brainstorm_categories();
|
||||
|
||||
// Verwerk nieuwe sessie aanmaken
|
||||
$error = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_session') {
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$theme = trim($_POST['theme'] ?? 'Product & Strategie');
|
||||
$theme = trim($_POST['category'] ?? ($_POST['theme'] ?? ''));
|
||||
if (empty($theme) && !empty($brainstormCategories)) {
|
||||
$firstActive = array_values(array_filter($brainstormCategories, fn($c) => !empty($c['is_active'])))[0] ?? null;
|
||||
$theme = $firstActive ? $firstActive['name'] : 'Product & Strategie';
|
||||
}
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$autoAi = !empty($_POST['auto_ai']) ? 1 : 0;
|
||||
|
||||
if (!empty($title)) {
|
||||
$stmt = $db->prepare("
|
||||
@@ -21,7 +28,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
||||
");
|
||||
$stmt->execute([$title, $theme, $description, $currentUser['id']]);
|
||||
$newId = $db->lastInsertId();
|
||||
header("Location: /brainstorm_canvas.php?id={$newId}");
|
||||
$redirectUrl = "/brainstorm_canvas.php?id={$newId}" . ($autoAi ? "&auto_ai=1" : "");
|
||||
header("Location: {$redirectUrl}");
|
||||
exit;
|
||||
} else {
|
||||
$error = "Titel van de brainstormsessie is verplicht.";
|
||||
@@ -29,41 +37,47 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
||||
}
|
||||
|
||||
// Haal alle sessies op
|
||||
$stmtSessions = $db->query("
|
||||
$sessions = [];
|
||||
try {
|
||||
$stmtSessions = $db->query("
|
||||
SELECT s.*, u.name as creator_name,
|
||||
(SELECT COUNT(*) FROM brainstorm_cards WHERE session_id = s.id) as cards_count
|
||||
FROM brainstorm_sessions s
|
||||
JOIN users u ON s.created_by_id = u.id
|
||||
ORDER BY s.created_at DESC
|
||||
");
|
||||
$sessions = $stmtSessions->fetchAll();
|
||||
");
|
||||
$sessions = $stmtSessions->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen brainstorm sessies: " . $e->getMessage());
|
||||
ensure_tables_exist($db);
|
||||
}
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-6 max-w-7xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
|
||||
<div class="fociz-card p-6 sm:p-7 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 text-amber-400 text-xs font-semibold uppercase tracking-wider mb-1">
|
||||
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4"></i>
|
||||
<span>Creatie & Innovatie</span>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
|
||||
Brainstorm Sessies
|
||||
</h1>
|
||||
<p class="text-slate-400 text-xs sm:text-sm mt-1">
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
||||
Brainstorm met collega's, verzamel ideeën en knelpunten, en transformeer ze direct tot een werkbare roadmap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-amber-500 hover:bg-amber-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-amber-500/20 flex-shrink-0">
|
||||
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs flex-shrink-0 cursor-pointer">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
<span>Nieuwe Sessie Starten</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-700 dark:text-rose-400 text-sm">
|
||||
<?= htmlspecialchars($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -71,97 +85,163 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<!-- Grid met sessies -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<?php if (empty($sessions)): ?>
|
||||
<div class="col-span-full bg-slate-900/60 border border-slate-800/80 rounded-2xl p-12 text-center">
|
||||
<i data-lucide="lightbulb" class="w-12 h-12 text-amber-500/40 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold text-white">Nog geen brainstormsessies gestart</h3>
|
||||
<p class="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
<div class="col-span-full fociz-card p-12 text-center shadow-xs">
|
||||
<i data-lucide="lightbulb" class="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white">Nog geen brainstormsessies gestart</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
Start uw eerste brainstormsessie om ideeën te verzamelen en ze met één klik om te vormen naar planningsitems.
|
||||
</p>
|
||||
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="mt-4 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold rounded-xl text-xs transition">
|
||||
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl text-xs transition cursor-pointer shadow-xs">
|
||||
Sessie Starten
|
||||
</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($sessions as $sess): ?>
|
||||
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="group bg-slate-900/80 hover:bg-slate-850 border border-slate-800 hover:border-amber-500/40 rounded-2xl p-5 shadow-lg transition flex flex-col justify-between">
|
||||
<div>
|
||||
<?php foreach ($sessions as $sess):
|
||||
$canDelete = ($currentUser['role'] === 'ADMIN' || (int)$sess['created_by_id'] === (int)$currentUser['id']);
|
||||
?>
|
||||
<div class="group fociz-card-interactive p-5 flex flex-col justify-between hover:border-indigo-400 dark:hover:border-indigo-500/60 relative">
|
||||
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="block flex-1">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 font-semibold text-[11px]">
|
||||
<span class="px-2.5 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 text-indigo-700 dark:text-indigo-400 font-semibold text-[11px]">
|
||||
<?= htmlspecialchars($sess['theme']) ?>
|
||||
</span>
|
||||
<span class="flex items-center space-x-1 text-slate-500 text-xs">
|
||||
<span class="flex items-center space-x-1 text-slate-400 dark:text-slate-500 text-xs font-medium">
|
||||
<i data-lucide="message-square" class="w-3.5 h-3.5"></i>
|
||||
<span><?= $sess['cards_count'] ?> kaarten</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-bold text-white group-hover:text-amber-400 transition leading-snug">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition leading-snug">
|
||||
<?= htmlspecialchars($sess['title']) ?>
|
||||
</h3>
|
||||
|
||||
<?php if (!empty($sess['description'])): ?>
|
||||
<p class="text-slate-400 text-xs mt-2 line-clamp-2">
|
||||
<p class="text-slate-600 dark:text-slate-400 text-xs mt-2 line-clamp-2 leading-relaxed">
|
||||
<?= htmlspecialchars($sess['description']) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="mt-5 pt-4 border-t border-slate-800/80 flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Door <?= htmlspecialchars($sess['creator_name']) ?></span>
|
||||
<div class="flex items-center space-x-1 text-amber-400 group-hover:translate-x-1 transition font-medium">
|
||||
<div class="mt-5 pt-4 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||
<span class="truncate">Door <?= htmlspecialchars($sess['creator_name']) ?></span>
|
||||
<div class="flex items-center space-x-2 flex-shrink-0 ml-2">
|
||||
<?php if ($canDelete): ?>
|
||||
<button
|
||||
type="button"
|
||||
onclick="promptDeleteSession(event, <?= (int)$sess['id'] ?>, '<?= htmlspecialchars(addslashes($sess['title']), ENT_QUOTES) ?>', <?= (int)$sess['cards_count'] ?>)"
|
||||
class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition cursor-pointer"
|
||||
title="Sessie verwijderen"
|
||||
>
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="flex items-center space-x-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-bold">
|
||||
<span>Open canvas</span>
|
||||
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nieuwe sessie -->
|
||||
<div id="newSessionModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full">
|
||||
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
|
||||
<h3 class="text-base font-bold text-white flex items-center space-x-2">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-amber-400"></i>
|
||||
<span>Nieuwe Brainstormsessie</span>
|
||||
</h3>
|
||||
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="text-slate-400 hover:text-white">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
<div id="newSessionModal" onclick="if(event.target === this) this.classList.add('hidden')" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-xl w-full my-6 space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-xl bg-indigo-50 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 flex items-center justify-center border border-indigo-100 dark:border-indigo-800/60">
|
||||
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Nieuwe Brainstormsessie</h3>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400">Creëer een canvas en voed de AI Co-Pilot met achtergrondinformatie</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/brainstorm.php" class="space-y-4">
|
||||
<input type="hidden" name="action" value="create_session">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Onderwerp / Titel</label>
|
||||
<input type="text" name="title" required placeholder="Bijv. Q4 Automatisering & Klantreis" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Onderwerp / Titel <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="title" required placeholder="Bijv. Modernisering Klantportaal & Self-service" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs font-semibold">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Thema / Categorie</label>
|
||||
<select name="theme" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Thema / Categorie</label>
|
||||
<select name="category" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<?php
|
||||
$hasActiveCategory = false;
|
||||
foreach ($brainstormCategories as $bCat):
|
||||
if (isset($bCat['is_active']) && empty($bCat['is_active'])) continue;
|
||||
$hasActiveCategory = true;
|
||||
?>
|
||||
<option value="<?= htmlspecialchars($bCat['name']) ?>"><?= htmlspecialchars($bCat['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$hasActiveCategory): ?>
|
||||
<option value="Product & Strategie">Product & Strategie</option>
|
||||
<option value="Procesverbetering">Procesverbetering</option>
|
||||
<option value="Klant & Service">Klant & Service</option>
|
||||
<option value="Technologie & IT">Technologie & IT</option>
|
||||
<option value="Marketing & Groei">Marketing & Groei</option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Doel / Beschrijving</label>
|
||||
<input type="text" name="description" placeholder="Doel van deze sessie..." class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button type="submit" class="flex-1 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition">
|
||||
Sessie Aanmaken & Canvas Openen
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Uitgebreide Briefing, Context & Doelstellingen
|
||||
</label>
|
||||
<span class="text-[10px] text-indigo-600 dark:text-indigo-400 font-medium">Voeding voor AI post-its</span>
|
||||
</div>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="6"
|
||||
placeholder="Voer hier alle achtergrondinformatie, context en doelen in waar de AI post-its van gaat maken. Bijvoorbeeld: • Achtergrond: Klanten klagen over lange wachttijden bij onboarding. • Doel: Binnen 3 maanden de doorlooptijd met 50% verkorten. • Bekende knelpunten: Handmatige validatie, ontbrekende API-koppeling met CRM. • Gewenste richting: Self-service portaal, automatische validatie, duidelijke statusupdates."
|
||||
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3.5 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs leading-relaxed resize-y font-normal"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400 mt-1">
|
||||
Tip: Hoe gedetailleerder de informatie, hoe gerichter en waardevoller de ideeën, knelpunten en oplossingen die AI genereert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- AI Direct Genereren Optie -->
|
||||
<div class="p-3.5 rounded-xl bg-violet-50/70 dark:bg-violet-950/25 border border-violet-200/80 dark:border-violet-800/40 flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="auto_ai_checkbox"
|
||||
name="auto_ai"
|
||||
value="1"
|
||||
checked
|
||||
class="w-4 h-4 mt-0.5 rounded text-violet-600 focus:ring-0 border-violet-300 dark:border-violet-700 cursor-pointer"
|
||||
>
|
||||
<label for="auto_ai_checkbox" class="text-xs cursor-pointer select-none">
|
||||
<span class="font-bold text-violet-900 dark:text-violet-200 block flex items-center space-x-1.5">
|
||||
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-violet-600 dark:text-violet-400"></i>
|
||||
<span>Direct AI post-its genereren op basis van deze sessiebeschrijving</span>
|
||||
</span>
|
||||
<span class="text-[11px] text-violet-700/80 dark:text-violet-300/80 block mt-0.5 leading-snug">
|
||||
AI Co-Pilot analyseert de ingevoerde context direct bij het openen en maakt automatisch gerichte post-its aan voor Ideeën, Knelpunten, Oplossingen en Quick Wins.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button type="submit" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-2">
|
||||
<i data-lucide="layout-grid" class="w-4 h-4"></i>
|
||||
<span>Sessie Aanmaken & Canvas Openen</span>
|
||||
</button>
|
||||
<button type="button" onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
|
||||
<button type="button" onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
@@ -169,5 +249,110 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
<!-- Modal: Sessie Verwijderen Bevestiging -->
|
||||
<div id="deleteSessionModal" onclick="if(event.target === this) closeDeleteSessionModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-md w-full space-y-4">
|
||||
<div class="flex items-start space-x-3.5 pb-2">
|
||||
<div class="w-10 h-10 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 text-rose-600 dark:text-rose-400 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white">Brainstormsessie verwijderen</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Weet u zeker dat u deze sessie wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3.5 rounded-xl bg-rose-50/60 dark:bg-rose-950/20 border border-rose-200/80 dark:border-rose-900/40 text-xs text-rose-800 dark:text-rose-300 space-y-1">
|
||||
<p class="font-bold text-slate-900 dark:text-white" id="deleteSessionTitle">—</p>
|
||||
<p id="deleteSessionWarning" class="text-[11px] text-rose-700 dark:text-rose-400">
|
||||
Alle bijbehorende ideeën, knelpunten en kaarten worden definitief gewist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button type="button" onclick="closeDeleteSessionModal()" class="flex-1 px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
Annuleren
|
||||
</button>
|
||||
<button type="button" id="btnConfirmDeleteSession" onclick="executeDeleteSession()" class="flex-1 px-4 py-2.5 bg-rose-600 hover:bg-rose-500 text-white font-bold rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
<span>Verwijderen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sessionToDeleteId = null;
|
||||
|
||||
function promptDeleteSession(e, sessionId, sessionTitle, cardsCount) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
sessionToDeleteId = sessionId;
|
||||
document.getElementById('deleteSessionTitle').textContent = sessionTitle || 'Brainstormsessie';
|
||||
const warningEl = document.getElementById('deleteSessionWarning');
|
||||
if (cardsCount > 0) {
|
||||
warningEl.textContent = `Let op: Deze sessie bevat ${cardsCount} brainstormkaart(en). Alle kaarten worden definitief verwijderd.`;
|
||||
} else {
|
||||
warningEl.textContent = 'Alle inhoud van deze sessie wordt definitief gewist.';
|
||||
}
|
||||
const modal = document.getElementById('deleteSessionModal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeDeleteSessionModal() {
|
||||
sessionToDeleteId = null;
|
||||
const modal = document.getElementById('deleteSessionModal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
|
||||
async function executeDeleteSession() {
|
||||
if (!sessionToDeleteId) return;
|
||||
|
||||
const btn = document.getElementById('btnConfirmDeleteSession');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Verwijderen...</span>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/brainstorm.php?action=delete_session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionToDeleteId })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
closeDeleteSessionModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Fout bij verwijderen: ' + (data.error || 'Onbekende fout'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Netwerkfout bij verwijderen: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('newSessionModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
closeDeleteSessionModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.4",
|
||||
"ext-pdo": "*",
|
||||
"ext-pdo_mysql": "*",
|
||||
"ext-mysqli": "*"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# =========================================================================
|
||||
# FOCIZ CONFIGURATIE (Centraal)
|
||||
# De omgeving en database worden automatisch veilig bepaald op basis van het domein:
|
||||
# - dev.fociz.nl / localhost => development (focizdev / focizdev_user)
|
||||
# - www.fociz.nl / fociz.nl => live (fociz / focizlive_user)
|
||||
# Optionele serverspecifieke overrides kunnen in de root /.env worden geplaatst.
|
||||
# =========================================================================
|
||||
|
||||
# MariaDB Server
|
||||
DB_HOST="192.168.2.33"
|
||||
DB_PORT="3306"
|
||||
|
||||
# Applicatie Geheime Sleutel (voor tokenversleuteling & OAuth state)
|
||||
APP_SECRET="fociz-maria-db-super-secret-key-998877"
|
||||
|
||||
# Microsoft 365 Entra ID Tenant
|
||||
MICROSOFT_TENANT_ID="planningsagenda.nl"
|
||||
|
||||
# SMTP Mail Server
|
||||
SMTP_HOST="mail.fociz.nl"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="info@fociz.nl"
|
||||
SMTP_PASS="6swpQSBg5uWFmjar7F6a"
|
||||
SMTP_SECURE="tls"
|
||||
SMTP_FROM_EMAIL="info@fociz.nl"
|
||||
SMTP_FROM_NAME="Fociz Dev"
|
||||
SMTP_REPLY_TO="info@fociz.nl"
|
||||
|
||||
# Webhooks (optioneel)
|
||||
JIRA_WEBHOOK_SECRET="fociz_webhook_dev_2026"
|
||||
@@ -1,26 +0,0 @@
|
||||
# MariaDB Verbinding
|
||||
DB_HOST="192.168.2.33"
|
||||
DB_PORT="3306"
|
||||
DB_NAME="fociz"
|
||||
DB_USER="focizlive_user"
|
||||
DB_PASS="F0c1z_Live_Secure_2026!"
|
||||
|
||||
# Omgeving: 'development' (knippert rood) of 'live' (knippert groen)
|
||||
APP_ENV="live"
|
||||
|
||||
# Applicatie URL (voor uitnodigingslinks en OAuth redirects: https://dev.fociz.nl of https://fociz.nl)
|
||||
APP_URL="https://www.fociz.nl"
|
||||
|
||||
# Applicatie Geheime Sleutel (willekeurige tekenreeks)
|
||||
APP_SECRET="fociz-maria-db-super-secret-key-998877"
|
||||
|
||||
# Microsoft 365 / Outlook (Optionele globale defaults; kan ook via web UI worden ingesteld)
|
||||
MICROSOFT_CLIENT_ID=""
|
||||
MICROSOFT_CLIENT_SECRET=""
|
||||
MICROSOFT_TENANT_ID="common"
|
||||
|
||||
# Atlassian Jira Cloud (Optionele globale defaults; kan ook via web UI worden ingesteld)
|
||||
JIRA_DOMAIN=""
|
||||
JIRA_EMAIL=""
|
||||
JIRA_API_TOKEN=""
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Beveiliging van de interne configuratiemap
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -1,36 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Applicatie- en Databaseconfiguratie
|
||||
* Laadt automatisch .env (of .env.example indien .env niet bestaat)
|
||||
* Fociz Centrale Applicatie- en Databaseconfiguratie
|
||||
* Alle configuratie staat overzichtelijk en veilig in dit ene bestand.
|
||||
*/
|
||||
|
||||
// Blokkeer directe HTTP toegang tot dit configuratiebestand (Pen Test Beveiliging)
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === 'config.php') {
|
||||
http_response_code(403);
|
||||
exit('Toegang geweigerd.');
|
||||
}
|
||||
|
||||
// Stuur direct strenge beveiligingsheaders mee op elk verzoek (Pen Test Beveiliging)
|
||||
if (!headers_sent()) {
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: SAMEORIGIN');
|
||||
header('X-XSS-Protection: 1; mode=block');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://unpkg.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'");
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
}
|
||||
header_remove('X-Powered-By');
|
||||
}
|
||||
@ini_set('expose_php', 'off');
|
||||
|
||||
// 1. .env inlezing (controleert eerst server root .env en dan config/.env)
|
||||
$envVars = [];
|
||||
|
||||
// 1. Zoek naar .env of val terug op config/.env.example
|
||||
$envCandidates = [
|
||||
__DIR__ . '/../.env',
|
||||
__DIR__ . '/.env.example',
|
||||
__DIR__ . '/../.env.example',
|
||||
];
|
||||
|
||||
foreach ($envCandidates as $envFile) {
|
||||
$envLocations = [__DIR__ . '/../.env', __DIR__ . '/.env'];
|
||||
foreach ($envLocations as $envFile) {
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines !== false) {
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
// Negeer lege regels en commentaarregels (# of //)
|
||||
if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, '//')) {
|
||||
continue;
|
||||
}
|
||||
if (strpos($line, '=') !== false) {
|
||||
list($name, $value) = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
// Verwijder omlijstende aanhalingstekens (" of ')
|
||||
$value = trim($value, " \t\n\r\0\x0B\"'");
|
||||
|
||||
// Sla op in array en zet in globale PHP variabelen
|
||||
$envVars[$name] = $value;
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
@@ -38,7 +48,7 @@ foreach ($envCandidates as $envFile) {
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // Stop zodra we het eerste geldige bestand hebben ingeladen
|
||||
break; // Eerste gevonden .env bestand is leidend
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,12 +75,58 @@ if (!function_exists('fociz_env')) {
|
||||
}
|
||||
}
|
||||
|
||||
// MariaDB Verbindingsgegevens (uit .env of .env.example)
|
||||
// =========================================================================
|
||||
// 2. APPLICATIE INSTELLINGEN & OMGEVINGSDETECTIE
|
||||
// =========================================================================
|
||||
define('APP_NAME', 'Fociz');
|
||||
|
||||
// Automatische omgevingsdetectie:
|
||||
// 1. Hostname/domeinnaam is altijd leidend voor productiebeveiliging
|
||||
$detectedHost = strtolower($_SERVER['HTTP_HOST'] ?? '');
|
||||
$isDevHost = (
|
||||
str_contains($detectedHost, 'dev.') ||
|
||||
str_contains($detectedHost, 'localhost') ||
|
||||
str_contains($detectedHost, '127.0.0.1')
|
||||
);
|
||||
$isLiveHost = (
|
||||
!empty($detectedHost) &&
|
||||
!$isDevHost &&
|
||||
(str_contains($detectedHost, 'fociz.nl') || str_contains($detectedHost, 'live'))
|
||||
);
|
||||
|
||||
// Bepaal APP_ENV: op live domeinen (bijv. www.fociz.nl of fociz.nl) ALTIJD 'live'
|
||||
// zodat een per ongeluk meegekomen dev-configuratie NOOIT productie kan beïnvloeden.
|
||||
if ($isLiveHost) {
|
||||
$effectiveEnv = 'live';
|
||||
} elseif ($isDevHost) {
|
||||
$effectiveEnv = 'development';
|
||||
} else {
|
||||
$effectiveEnv = fociz_env('APP_ENV', 'live');
|
||||
}
|
||||
define('APP_ENV', $effectiveEnv);
|
||||
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
|
||||
|
||||
// =========================================================================
|
||||
// 3. CENTRALE DATABASE CONFIGURATIE (OMGEVINGSAFHANKELIJK & GIT-VEILIG)
|
||||
// =========================================================================
|
||||
$isDevEnv = (APP_ENV === 'development');
|
||||
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
|
||||
define('DB_PORT', fociz_env('DB_PORT', '3306'));
|
||||
define('DB_NAME', fociz_env('DB_NAME', 'fociz'));
|
||||
define('DB_USER', fociz_env('DB_USER', 'focizlive_user'));
|
||||
define('DB_PASS', fociz_env('DB_PASS', 'F0c1z_Live_Secure_2026!'));
|
||||
|
||||
// Veiligheidswaarborg: op het live domein of live omgeving NOOIT per ongeluk de dev database gebruiken
|
||||
if ($isLiveHost || APP_ENV === 'live') {
|
||||
$rawDbName = fociz_env('DB_NAME');
|
||||
$rawDbUser = fociz_env('DB_USER');
|
||||
$rawDbPass = fociz_env('DB_PASS');
|
||||
|
||||
define('DB_NAME', (!empty($rawDbName) && $rawDbName !== 'focizdev') ? $rawDbName : 'fociz');
|
||||
define('DB_USER', (!empty($rawDbUser) && $rawDbUser !== 'focizdev_user') ? $rawDbUser : 'focizlive_user');
|
||||
define('DB_PASS', (!empty($rawDbPass) && $rawDbPass !== 'F0c1z_Dev_Secure_2026!') ? $rawDbPass : 'F0c1z_Live_Secure_2026!');
|
||||
} else {
|
||||
define('DB_NAME', fociz_env('DB_NAME', 'focizdev'));
|
||||
define('DB_USER', fociz_env('DB_USER', 'focizdev_user'));
|
||||
define('DB_PASS', fociz_env('DB_PASS', 'F0c1z_Dev_Secure_2026!'));
|
||||
}
|
||||
define('DB_CHARSET', 'utf8mb4');
|
||||
|
||||
// Bepaal de dynamische of geconfigureerde applicatie URL
|
||||
@@ -81,7 +137,7 @@ if (!function_exists('fociz_detect_app_url')) {
|
||||
return rtrim($envUrl, '/');
|
||||
}
|
||||
|
||||
// Dynamische detectie op basis van het huidige domein (werkt direct op dev.fociz.nl én fociz.nl)
|
||||
// Dynamische detectie op basis van het huidige domein (werkt direct op www.fociz.nl én dev.fociz.nl)
|
||||
if (!empty($_SERVER['HTTP_HOST'])) {
|
||||
$isHttps = (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
||||
@@ -93,22 +149,59 @@ if (!function_exists('fociz_detect_app_url')) {
|
||||
return $scheme . $_SERVER['HTTP_HOST'];
|
||||
}
|
||||
|
||||
// Standaard fallback per omgeving
|
||||
return (defined('APP_ENV') && APP_ENV === 'live') ? 'https://fociz.nl' : 'https://dev.fociz.nl';
|
||||
return (defined('APP_ENV') && APP_ENV === 'live') ? 'https://www.fociz.nl' : 'https://dev.fociz.nl';
|
||||
}
|
||||
}
|
||||
|
||||
// Applicatie instellingen
|
||||
define('APP_NAME', 'Fociz');
|
||||
define('APP_ENV', fociz_env('APP_ENV', 'development')); // 'development' of 'live'
|
||||
define('APP_URL', fociz_detect_app_url());
|
||||
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
|
||||
|
||||
// Tijdzone
|
||||
date_default_timezone_set('Europe/Amsterdam');
|
||||
|
||||
// Foutrapportage: in development tonen we fouten direct op het scherm
|
||||
if (APP_ENV !== 'live') {
|
||||
// =========================================================================
|
||||
// 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', 'info@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
|
||||
// =========================================================================
|
||||
if (APP_ENV === 'live') {
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('display_startup_errors', '0');
|
||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);
|
||||
|
||||
// Registreer een nette noodfoutpagina voor onverhoopte fatale PHP fouten
|
||||
register_shutdown_function(function() {
|
||||
$error = error_get_last();
|
||||
if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
|
||||
error_log("Fociz Fatal Error: [{$error['type']}] {$error['message']} in {$error['file']}:{$error['line']}");
|
||||
$showDiagnostics = (isset($_SESSION['user_role']) && $_SESSION['user_role'] === 'ADMIN');
|
||||
if ($showDiagnostics) {
|
||||
echo "<div style='font-family: monospace; background: #0c0f17; color: #ef4444; padding: 25px; margin: 20px; border-radius: 12px; border: 1px solid #dc2626;'>
|
||||
<h3>⚠️ Fociz Systeemfout (Diagnostiek)</h3>
|
||||
<p><strong>Fout:</strong> " . htmlspecialchars($error['message']) . "</p>
|
||||
<p><strong>Bestand:</strong> " . htmlspecialchars($error['file']) . " op regel " . $error['line'] . "</p>
|
||||
</div>";
|
||||
} else {
|
||||
echo "<div style='font-family: sans-serif; background: #0c0f17; color: #f8fafc; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='max-width: 480px; text-align: center; padding: 40px;'>
|
||||
<div style='width: 48px; height: 48px; margin: 0 auto 16px; border-radius: 12px; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); display: flex; align-items: center; justify-content: center; color: #818cf8; font-weight: bold;'>F</div>
|
||||
<h2 style='font-size: 20px; margin-bottom: 8px;'>Er is iets misgegaan</h2>
|
||||
<p style='color: #94a3b8; font-size: 14px; line-height: 1.5;'>De pagina kon niet worden geladen. Probeer de pagina te vernieuwen of log opnieuw in.</p>
|
||||
<a href='/auth/login.php' style='display: inline-block; margin-top: 20px; padding: 10px 20px; border-radius: 8px; background: #4f46e5; color: white; text-decoration: none; font-size: 13px; font-weight: 600;'>Naar Inloggen</a>
|
||||
</div>
|
||||
</div>";
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* MariaDB PDO Databaseverbinding & Automatische Migratie
|
||||
* MariaDB PDO Databaseverbinding & Automatische Tabellencontrole
|
||||
*/
|
||||
|
||||
// Blokkeer directe HTTP toegang (Pen Test Beveiliging)
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === 'database.php') {
|
||||
http_response_code(403);
|
||||
exit('Toegang geweigerd.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
function get_db_connection(): PDO {
|
||||
@@ -15,17 +21,18 @@ function get_db_connection(): PDO {
|
||||
// 1. Controleer of de PDO MySQL/MariaDB driver aanwezig is in PHP
|
||||
$availableDrivers = PDO::getAvailableDrivers();
|
||||
if (!in_array('mysql', $availableDrivers)) {
|
||||
$driversList = !empty($availableDrivers) ? implode(', ', $availableDrivers) : 'geen';
|
||||
error_log("PDO MySQL driver (pdo_mysql) ontbreekt in PHP.");
|
||||
die("
|
||||
<div style='font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; background: #090d16; color: #f1f5f9; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px;'>
|
||||
<div style='max-width: 580px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.5);'>
|
||||
<h2 style='color: #ef4444; margin-top: 0;'>⚠️ PHP MySQL Driver Ontbreekt (pdo_mysql)</h2>
|
||||
<div style='font-family: sans-serif; background: #0c0f17; color: #f1f5f9; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px;'>
|
||||
<div style='max-width: 580px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px;'>
|
||||
<h2 style='color: #ef4444; margin-top: 0;'>⚠️ Database Driver Ontbreekt</h2>
|
||||
<p style='color: #94a3b8; font-size: 14px;'>De extensie <strong>pdo_mysql</strong> is niet actief in deze PHP-omgeving.</p>
|
||||
</div>
|
||||
</div>
|
||||
");
|
||||
}
|
||||
|
||||
$tzOffset = date('P'); // Dynamische offset conform Europe/Amsterdam (+02:00 in zomertijd, +01:00 in wintertijd)
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
@@ -35,6 +42,9 @@ function get_db_connection(): PDO {
|
||||
if (defined('PDO::MYSQL_ATTR_MULTI_STATEMENTS')) {
|
||||
$options[PDO::MYSQL_ATTR_MULTI_STATEMENTS] = true;
|
||||
}
|
||||
if (defined('PDO::MYSQL_ATTR_INIT_COMMAND')) {
|
||||
$options[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET time_zone = '{$tzOffset}'";
|
||||
}
|
||||
|
||||
$dsnWithDb = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
@@ -45,19 +55,18 @@ function get_db_connection(): PDO {
|
||||
);
|
||||
|
||||
// 2. CHECK: Bestaat de database al?
|
||||
// We proberen eerst direct verbinding te maken met de database.
|
||||
// Als de database al bestaat, lukt dit direct zonder database-aanmaakrechten te vereisen!
|
||||
try {
|
||||
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
|
||||
|
||||
// De database bestaat al! Controleer enkel of de tabellen al aanwezig zijn
|
||||
// Garandeer dat MariaDB sessietijd 1-op-1 synchroon loopt met PHP tijd (Europe/Amsterdam)
|
||||
$pdo->exec("SET time_zone = '{$tzOffset}'");
|
||||
ensure_tables_exist($pdo);
|
||||
return $pdo;
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("MariaDB Verbindingsfout [{$e->getCode()}]: {$e->getMessage()}");
|
||||
|
||||
// Foutcode 1049 betekent: Database 'DB_NAME' bestaat nog NIET op de MariaDB server
|
||||
if ($e->getCode() == 1049 || str_contains($e->getMessage(), 'Unknown database')) {
|
||||
// Probeer de database eenmalig aan te maken indien de gebruiker CREATE rechten heeft
|
||||
try {
|
||||
$serverDsn = sprintf('mysql:host=%s;port=%s;charset=%s', DB_HOST, DB_PORT, DB_CHARSET);
|
||||
$serverPdo = new PDO($serverDsn, DB_USER, DB_PASS, $options);
|
||||
@@ -66,34 +75,32 @@ function get_db_connection(): PDO {
|
||||
DB_NAME
|
||||
));
|
||||
|
||||
// Nu opnieuw verbinden met de nieuw aangemaakte database
|
||||
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
|
||||
$pdo->exec("SET time_zone = '{$tzOffset}'");
|
||||
ensure_tables_exist($pdo);
|
||||
return $pdo;
|
||||
|
||||
} catch (PDOException $createEx) {
|
||||
die("
|
||||
<div style='font-family: sans-serif; background: #090d16; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='max-width: 500px; background: #111827; border: 1px solid #f59e0b; border-radius: 16px; padding: 30px; text-align: left;'>
|
||||
<h2 style='color: #f59e0b; margin-top: 0;'>⚠️ Database '" . htmlspecialchars(DB_NAME) . "' bestaat nog niet</h2>
|
||||
<p style='color: #94a3b8; font-size: 14px; line-height: 1.6;'>
|
||||
De database <strong>" . htmlspecialchars(DB_NAME) . "</strong> bestaat nog niet op MariaDB server <strong>" . htmlspecialchars(DB_HOST) . "</strong> en de gebruiker <em>" . htmlspecialchars(DB_USER) . "</em> heeft geen rechten om automatisch databases aan te maken.
|
||||
</p>
|
||||
<p style='color: #cbd5e1; font-size: 13px;'>
|
||||
Maak de database <code>" . htmlspecialchars(DB_NAME) . "</code> eenmalig aan via phpMyAdmin, HeidiSQL of de MariaDB CLI:
|
||||
</p>
|
||||
<code style='display: block; background: #000; color: #4ade80; padding: 10px; border-radius: 8px; font-size: 12px; margin: 10px 0;'>
|
||||
CREATE DATABASE IF NOT EXISTS `" . htmlspecialchars(DB_NAME) . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
");
|
||||
error_log("Kan database " . DB_NAME . " niet automatisch aanmaken: " . $createEx->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Andere verbindingsfout (bijv. verkeerd wachtwoord, poort of host onbereikbaar)
|
||||
// Pen Test beveiliging: in live omgeving geen interne IP's of database-details tonen!
|
||||
if (defined('APP_ENV') && APP_ENV === 'live') {
|
||||
die("
|
||||
<div style='font-family: sans-serif; background: #090d16; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='font-family: sans-serif; background: #0c0f17; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='max-width: 480px; background: #111827; border: 1px solid #334155; border-radius: 16px; padding: 30px; text-align: center;'>
|
||||
<div style='width: 48px; height: 48px; margin: 0 auto 16px; border-radius: 12px; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); display: flex; align-items: center; justify-content: center; color: #f87171; font-weight: bold;'>⚠️</div>
|
||||
<h2 style='color: #f87171; margin-top: 0; font-size: 18px;'>Database Tijdelijk Niet Bereikbaar</h2>
|
||||
<p style='color: #94a3b8; font-size: 13px; line-height: 1.6;'>
|
||||
Er kon geen veilige verbinding tot stand worden gebracht met de database. Probeer het over enkele ogenblikken opnieuw.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
");
|
||||
} else {
|
||||
die("
|
||||
<div style='font-family: sans-serif; background: #0c0f17; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='max-width: 520px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px; text-align: left;'>
|
||||
<h2 style='color: #ef4444; margin-top: 0;'>MariaDB Verbindingsfout (" . htmlspecialchars((string)$e->getCode()) . ")</h2>
|
||||
<p style='color: #cbd5e1; font-size: 14px;'>" . htmlspecialchars($e->getMessage()) . "</p>
|
||||
@@ -107,36 +114,202 @@ function get_db_connection(): PDO {
|
||||
</div>
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zorgt ervoor dat alle benodigde tabellen uit schema.sql aanwezig zijn in MariaDB
|
||||
* Voert niets uit als de tabellen al bestaan!
|
||||
* Zorgt ervoor dat ALLE benodigde tabellen en kolommen uit schema.sql aanwezig zijn in MariaDB
|
||||
* Voert elke tabel individueel uit zodat ontbrekende tabellen direct aangemaakt worden.
|
||||
*/
|
||||
function ensure_tables_exist(PDO $pdo): void {
|
||||
try {
|
||||
// Controleer of de hoofdtabel 'users' al bestaat
|
||||
$result = $pdo->query("SHOW TABLES LIKE 'users'");
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
// Tabellen bestaan al! Geen actie nodig.
|
||||
static $tablesChecked = false;
|
||||
if ($tablesChecked) return;
|
||||
$tablesChecked = true;
|
||||
|
||||
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
|
||||
$lockFile = sys_get_temp_dir() . '/fociz_schema_' . md5(__DIR__) . '.lock';
|
||||
|
||||
if (file_exists($lockFile) && file_exists($schemaFile) && filemtime($lockFile) >= filemtime($schemaFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tabellen ontbreken nog: voer schema.sql uit statement voor statement
|
||||
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
|
||||
try {
|
||||
if (file_exists($schemaFile)) {
|
||||
$sql = file_get_contents($schemaFile);
|
||||
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||
foreach ($statements as $stmt) {
|
||||
// Verwijder eventuele SQL commentaren aan het begin
|
||||
$stmtClean = preg_replace('/^--.*$/m', '', $stmt);
|
||||
$stmtClean = trim($stmtClean);
|
||||
if (!empty($stmtClean)) {
|
||||
try {
|
||||
$pdo->exec($stmtClean);
|
||||
} catch (Throwable $t) {
|
||||
// Stil negeren indien tabel of relatie al bestaat
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("Fout bij aanmaken tabellen: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Zorg ervoor dat alle kolommen en upgrades aanwezig zijn
|
||||
$colUpgrades = [
|
||||
"ALTER TABLE tasks MODIFY external_id VARCHAR(255) NULL",
|
||||
"ALTER TABLE tasks ADD COLUMN external_url TEXT NULL AFTER external_id",
|
||||
"ALTER TABLE users ADD COLUMN remember_token VARCHAR(64) NULL AFTER backup_codes",
|
||||
"ALTER TABLE users ADD COLUMN remember_token_expires_at DATETIME NULL AFTER remember_token",
|
||||
"ALTER TABLE users ADD COLUMN notification_settings TEXT NULL AFTER remember_token_expires_at",
|
||||
"ALTER TABLE users ADD COLUMN theme VARCHAR(20) NOT NULL DEFAULT 'dark' AFTER notification_settings",
|
||||
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255) NULL AFTER theme",
|
||||
"ALTER TABLE users MODIFY COLUMN avatar_url MEDIUMTEXT NULL",
|
||||
"ALTER TABLE users ADD COLUMN backup_codes TEXT NULL AFTER two_factor_enabled",
|
||||
"ALTER TABLE users ADD COLUMN default_jira_filter_code VARCHAR(50) NULL AFTER avatar_url",
|
||||
"ALTER TABLE users ADD COLUMN fociz_widget_layout TEXT NULL AFTER default_jira_filter_code",
|
||||
"ALTER TABLE users ADD COLUMN last_mail_check_at DATETIME NULL AFTER fociz_widget_layout",
|
||||
"ALTER TABLE users ADD COLUMN last_jira_check_at DATETIME NULL AFTER last_mail_check_at",
|
||||
"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",
|
||||
"CREATE TABLE IF NOT EXISTS `jira_queue_filters` (`id` INT AUTO_INCREMENT PRIMARY KEY, `filter_code` VARCHAR(50) NOT NULL UNIQUE, `filter_name` VARCHAR(120) NOT NULL, `project_key` VARCHAR(50) NULL, `jql_clause` TEXT NOT NULL, `description` VARCHAR(255) NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `sort_order` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// Jira: Meerdere wachtrijfilters per teamlid
|
||||
"ALTER TABLE users ADD COLUMN default_jira_filter_codes TEXT NULL AFTER default_jira_filter_code",
|
||||
// V2.0: Subtaken & Checklists
|
||||
"CREATE TABLE IF NOT EXISTS `task_subtasks` (`id` INT AUTO_INCREMENT PRIMARY KEY, `task_id` INT NOT NULL, `user_id` INT NOT NULL, `title` VARCHAR(255) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`task_id`, `position`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// Beveiliging: IP tracking & locatie-verificatie
|
||||
"ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45) NULL AFTER last_jira_check_at",
|
||||
"ALTER TABLE users ADD COLUMN last_login_at DATETIME NULL AFTER last_login_ip",
|
||||
"CREATE TABLE IF NOT EXISTS `user_known_ips` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `is_verified` TINYINT(1) NOT NULL DEFAULT 1, `verify_token` VARCHAR(64) NULL, `verify_token_expires_at` DATETIME NULL, `first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`), INDEX (`verify_token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// SOC 2 & ISO 27001: Onwijzigbare Audit Trail
|
||||
"CREATE TABLE IF NOT EXISTS `audit_logs` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NULL, `user_email` VARCHAR(191) NULL, `action` VARCHAR(64) NOT NULL, `resource_type` VARCHAR(64) NULL, `resource_id` INT NULL, `details` TEXT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`user_id`), INDEX (`action`), INDEX (`created_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// Agenda & Verlof: Hele of meerdere dagen blokkeren (Vakantie, Vrije dag, Ziekte)
|
||||
"CREATE TABLE IF NOT EXISTS `calendar_blocks` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `start_date` DATE NOT NULL, `end_date` DATE NOT NULL, `title` VARCHAR(255) NOT NULL, `block_type` VARCHAR(50) NOT NULL DEFAULT 'VACATION', `notes` TEXT NULL, `outlook_event_id` VARCHAR(255) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `start_date`, `end_date`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// Centrale Systeeminstellingen (AI, Werkdagen, Sessiebeveiliging)
|
||||
"CREATE TABLE IF NOT EXISTS `system_settings` (`setting_key` VARCHAR(64) PRIMARY KEY, `setting_value` LONGTEXT NULL, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"ALTER TABLE `system_settings` MODIFY COLUMN `setting_value` LONGTEXT NULL",
|
||||
// Brainstorm kaarten toewijzen aan collega's en agenda-inplanning
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN assignee_id INT NULL AFTER color",
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_date DATE NULL AFTER assignee_id",
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_start_time VARCHAR(10) NULL AFTER scheduled_date",
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
|
||||
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
|
||||
// Dashboard Snelle Bullet Notities & Todo Lijst
|
||||
"CREATE TABLE IF NOT EXISTS `dashboard_bullets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `plan_date` DATE NOT NULL, `content` VARCHAR(500) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `scheduled_task_id` INT NULL, `scheduled_date` DATE NULL, `scheduled_start_time` VARCHAR(10) NULL, `scheduled_end_time` VARCHAR(10) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `plan_date`), INDEX (`scheduled_task_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
foreach ($colUpgrades as $sql) {
|
||||
try {
|
||||
$pdo->exec($sql);
|
||||
} catch (Throwable $t) {
|
||||
// Stil negeren indien kolom al bestaat
|
||||
}
|
||||
}
|
||||
|
||||
// Zorg dat de standaard Jira wachtrijfilters (SD, IN, AAN, GEN, DX) aanwezig zijn
|
||||
try {
|
||||
$existingCodes = $pdo->query("SELECT filter_code FROM jira_queue_filters")->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
$defaultFilters = [
|
||||
[
|
||||
'code' => 'SD',
|
||||
'name' => 'SD — Servicedesk',
|
||||
'project' => 'SD',
|
||||
'jql' => 'project = "SD"',
|
||||
'desc' => 'Servicedesk supportvragen en operationele meldingen.',
|
||||
'order' => 1
|
||||
],
|
||||
[
|
||||
'code' => 'IN',
|
||||
'name' => 'IN — Interfaces',
|
||||
'project' => 'IN',
|
||||
'jql' => 'project = "IN"',
|
||||
'desc' => 'Interface-koppelingen, API-endpoints en data-uitwisselingen.',
|
||||
'order' => 2
|
||||
],
|
||||
[
|
||||
'code' => 'AAN',
|
||||
'name' => 'AAN — Aanvragen',
|
||||
'project' => 'AAN',
|
||||
'jql' => 'project = "AAN"',
|
||||
'desc' => 'Klant- en gebruikersaanvragen en wijzigingsverzoeken.',
|
||||
'order' => 3
|
||||
],
|
||||
[
|
||||
'code' => 'GEN',
|
||||
'name' => 'GEN — General',
|
||||
'project' => 'GEN',
|
||||
'jql' => 'project = "GEN"',
|
||||
'desc' => 'Algemene taken, organisatie en platformprojecten.',
|
||||
'order' => 4
|
||||
],
|
||||
[
|
||||
'code' => 'DX',
|
||||
'name' => 'DX — Koppelingen & SSO',
|
||||
'project' => 'SD',
|
||||
'jql' => '"Onderwerp logging[dropdown]" in (Koppelingen, SSO-koppeling, Rest-endpoints, Datadump, UWV-koppeling, Migratie)',
|
||||
'desc' => 'Servicedesk tickets specifiek voor koppelingen en migraties.',
|
||||
'order' => 5
|
||||
]
|
||||
];
|
||||
$insertFilterStmt = $pdo->prepare("
|
||||
INSERT INTO jira_queue_filters (filter_code, filter_name, project_key, jql_clause, description, is_active, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?)
|
||||
");
|
||||
foreach ($defaultFilters as $df) {
|
||||
if (!in_array($df['code'], $existingCodes)) {
|
||||
$insertFilterStmt->execute([
|
||||
$df['code'], $df['name'], $df['project'], $df['jql'], $df['desc'], $df['order']
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien tabel nog in opbouw is
|
||||
}
|
||||
|
||||
// Zorg dat er ten minste één standaard bord met standaard lanes aanwezig is
|
||||
try {
|
||||
$boardCount = (int)$pdo->query("SELECT COUNT(*) FROM boards")->fetchColumn();
|
||||
if ($boardCount === 0) {
|
||||
$firstAdminId = (int)$pdo->query("SELECT id FROM users ORDER BY (role = 'ADMIN') DESC, id ASC LIMIT 1")->fetchColumn();
|
||||
if ($firstAdminId > 0) {
|
||||
$pdo->prepare("INSERT INTO boards (title, description, created_by_id) VALUES (?, ?, ?)")
|
||||
->execute(['Hoofdbord', 'Standaard team- en takenbord voor opdrachten', $firstAdminId]);
|
||||
$boardId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Maak beheerder lid van het bord
|
||||
$pdo->prepare("INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'ADMIN')")
|
||||
->execute([$boardId, $firstAdminId]);
|
||||
|
||||
// Maak de 4 standaard lanes aan
|
||||
$defaultLanes = [
|
||||
['title' => 'Open', 'color' => 'slate', 'pos' => 0],
|
||||
['title' => 'In uitvoering', 'color' => 'amber', 'pos' => 1],
|
||||
['title' => 'Testen', 'color' => 'purple', 'pos' => 2],
|
||||
['title' => 'Klaar', 'color' => 'emerald', 'pos' => 3],
|
||||
];
|
||||
$laneStmt = $pdo->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)");
|
||||
foreach ($defaultLanes as $l) {
|
||||
$laneStmt->execute([$boardId, $l['title'], $l['color'], $l['pos']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien borden tabel nog niet bestaat
|
||||
}
|
||||
|
||||
// Zorg dat eerdere audit logs die in UTC (+00:00) zijn weggeschreven worden gesynchroniseerd naar Europe/Amsterdam (+2 uur)
|
||||
try {
|
||||
$stmtCheck = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'audit_logs_tz_fixed' LIMIT 1");
|
||||
$fixed = $stmtCheck ? $stmtCheck->fetchColumn() : null;
|
||||
if (!$fixed) {
|
||||
// Pas bestaande audit logs eenmalig aan zodat de weergave klopt met de lokale tijd
|
||||
$pdo->exec("UPDATE audit_logs SET created_at = DATE_ADD(created_at, INTERVAL 2 HOUR)");
|
||||
$pdo->prepare("
|
||||
INSERT INTO system_settings (setting_key, setting_value, updated_at)
|
||||
VALUES ('audit_logs_tz_fixed', '1', NOW())
|
||||
ON DUPLICATE KEY UPDATE setting_value = '1', updated_at = NOW()
|
||||
")->execute();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien system_settings tabel nog niet bestaat
|
||||
}
|
||||
@touch($lockFile);
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij controleren tabellen: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
http_response_code(403);
|
||||
exit('Toegang geweigerd.');
|
||||
@@ -13,7 +13,13 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
`remember_token` VARCHAR(64) NULL,
|
||||
`remember_token_expires_at` DATETIME NULL,
|
||||
`notification_settings` TEXT NULL,
|
||||
`avatar_url` VARCHAR(255) NULL,
|
||||
`avatar_url` MEDIUMTEXT NULL,
|
||||
`default_jira_filter_code` VARCHAR(50) NULL,
|
||||
`fociz_widget_layout` TEXT NULL,
|
||||
`last_mail_check_at` DATETIME NULL,
|
||||
`last_jira_check_at` DATETIME NULL,
|
||||
`last_login_ip` VARCHAR(45) NULL,
|
||||
`last_login_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -31,6 +37,17 @@ CREATE TABLE IF NOT EXISTS `invites` (
|
||||
CONSTRAINT `fk_invites_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) 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` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
@@ -57,7 +74,7 @@ CREATE TABLE IF NOT EXISTS `tasks` (
|
||||
`duration_minutes` INT NULL DEFAULT 30,
|
||||
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`source` VARCHAR(50) NOT NULL DEFAULT 'FOCIZ',
|
||||
`external_id` VARCHAR(100) NULL,
|
||||
`external_id` VARCHAR(255) NULL,
|
||||
`external_url` TEXT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -84,12 +101,18 @@ CREATE TABLE IF NOT EXISTS `brainstorm_cards` (
|
||||
`content` TEXT NOT NULL,
|
||||
`category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën',
|
||||
`color` VARCHAR(20) NOT NULL DEFAULT 'amber',
|
||||
`assignee_id` INT NULL,
|
||||
`scheduled_date` DATE NULL,
|
||||
`scheduled_start_time` VARCHAR(10) NULL,
|
||||
`scheduled_end_time` VARCHAR(10) NULL,
|
||||
`scheduled_task_id` INT NULL,
|
||||
`votes` INT NOT NULL DEFAULT 0,
|
||||
`author_name` VARCHAR(120) NULL,
|
||||
`converted_to_roadmap_item_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`session_id`),
|
||||
INDEX (`assignee_id`),
|
||||
CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -128,4 +151,187 @@ CREATE TABLE IF NOT EXISTS `integrations` (
|
||||
UNIQUE KEY `uk_user_provider` (`user_id`, `provider`),
|
||||
CONSTRAINT `fk_integrations_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CREATE TABLE IF NOT EXISTS `system_settings` (
|
||||
`setting_key` VARCHAR(64) PRIMARY KEY,
|
||||
`setting_value` TEXT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz Kanban / Trello Borden, Lanes, Kaarten & Tijdspadkoppelingen
|
||||
CREATE TABLE IF NOT EXISTS `boards` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`created_by_id` INT NOT NULL,
|
||||
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`created_by_id`),
|
||||
CONSTRAINT `fk_boards_creator` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `board_members` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`board_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`role` ENUM('ADMIN', 'MEMBER', 'VIEWER') NOT NULL DEFAULT 'MEMBER',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_board_user` (`board_id`, `user_id`),
|
||||
CONSTRAINT `fk_bmembers_board` FOREIGN KEY (`board_id`) REFERENCES `boards` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_bmembers_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `board_lanes` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`board_id` INT NOT NULL,
|
||||
`title` VARCHAR(100) NOT NULL,
|
||||
`color` VARCHAR(30) NOT NULL DEFAULT 'slate',
|
||||
`position` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`board_id`, `position`),
|
||||
CONSTRAINT `fk_blanes_board` FOREIGN KEY (`board_id`) REFERENCES `boards` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `board_cards` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`board_id` INT NOT NULL,
|
||||
`lane_id` INT NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`assignee_id` INT NULL,
|
||||
`priority` ENUM('P1', 'P2', 'P3') NOT NULL DEFAULT 'P2',
|
||||
`color` VARCHAR(30) NOT NULL DEFAULT 'yellow',
|
||||
`position` INT NOT NULL DEFAULT 0,
|
||||
`created_by_id` INT NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`board_id`, `lane_id`, `position`),
|
||||
INDEX (`assignee_id`),
|
||||
CONSTRAINT `fk_bcards_board` FOREIGN KEY (`board_id`) REFERENCES `boards` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_bcards_lane` FOREIGN KEY (`lane_id`) REFERENCES `board_lanes` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_bcards_assignee` FOREIGN KEY (`assignee_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_bcards_creator` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `board_card_schedules` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`card_id` INT NOT NULL,
|
||||
`schedule_type` ENUM('SINGLE', 'DAILY_RANGE') NOT NULL DEFAULT 'SINGLE',
|
||||
`start_date` DATE NOT NULL,
|
||||
`end_date` DATE NOT NULL,
|
||||
`start_time` VARCHAR(10) NOT NULL DEFAULT '14:00',
|
||||
`end_time` VARCHAR(10) NOT NULL DEFAULT '15:00',
|
||||
`days_of_week` VARCHAR(50) NULL DEFAULT '1,2,3,4,5',
|
||||
`outlook_event_id` VARCHAR(255) NULL,
|
||||
`outlook_synced_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_sched_card` (`card_id`),
|
||||
CONSTRAINT `fk_bsched_card` FOREIGN KEY (`card_id`) REFERENCES `board_cards` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `jira_queue_filters` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`filter_code` VARCHAR(50) NOT NULL UNIQUE,
|
||||
`filter_name` VARCHAR(120) NOT NULL,
|
||||
`project_key` VARCHAR(50) NULL,
|
||||
`jql_clause` TEXT NOT NULL,
|
||||
`description` VARCHAR(255) NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_cache` (
|
||||
`cache_key` VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
`cache_value` MEDIUMTEXT NOT NULL,
|
||||
`expires_at` INT UNSIGNED NOT NULL,
|
||||
INDEX `idx_app_cache_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz V2.0: Subtaken & Checklists per taak
|
||||
CREATE TABLE IF NOT EXISTS `task_subtasks` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`task_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`position` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`task_id`, `position`),
|
||||
CONSTRAINT `fk_subtasks_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_subtasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz Beveiliging: Bekende IP-adressen en verificatie per gebruiker
|
||||
CREATE TABLE IF NOT EXISTS `user_known_ips` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`is_verified` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`verify_token` VARCHAR(64) NULL,
|
||||
`verify_token_expires_at` DATETIME NULL,
|
||||
`first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`),
|
||||
INDEX (`verify_token`),
|
||||
CONSTRAINT `fk_known_ips_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz SOC 2 & ISO 27001: Onwijzigbare Audit Trail (Beveiligings- en beheeracties)
|
||||
CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NULL,
|
||||
`user_email` VARCHAR(191) NULL,
|
||||
`action` VARCHAR(64) NOT NULL,
|
||||
`resource_type` VARCHAR(64) NULL,
|
||||
`resource_id` INT NULL,
|
||||
`details` TEXT NULL,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (`user_id`),
|
||||
INDEX (`action`),
|
||||
INDEX (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz SOC 2 CC6.1 & ISO 27001 A.9.4.2: Actieve Sessietracking & Remote Revocation
|
||||
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
`session_id_hash` VARCHAR(64) NOT NULL UNIQUE,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`device_name` VARCHAR(100) NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`last_activity_at` DATETIME NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (`user_id`),
|
||||
INDEX (`session_id_hash`),
|
||||
INDEX (`expires_at`),
|
||||
INDEX (`is_active`),
|
||||
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz Agenda & Verlof: Hele of meerdere dagen blokkeren (Vakantie, Vrije dag, Ziekte)
|
||||
CREATE TABLE IF NOT EXISTS `calendar_blocks` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
`start_date` DATE NOT NULL,
|
||||
`end_date` DATE NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`block_type` VARCHAR(50) NOT NULL DEFAULT 'VACATION',
|
||||
`notes` TEXT NULL,
|
||||
`outlook_event_id` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`user_id`, `start_date`, `end_date`),
|
||||
CONSTRAINT `fk_calblocks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="focizGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="50%" stop-color="#4f46e5"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<filter id="focizShadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4f46e5" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Achtergrond met afgeronde hoeken (superellipse / squircl) -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#focizGrad)" filter="url(#focizShadow)"/>
|
||||
<!-- Subtiele binnenrand highlight -->
|
||||
<rect x="3" y="3" width="58" height="58" rx="15" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- Karakteristieke Fociz 'F' -->
|
||||
<path d="M22 17 H43 C44.6569 17 46 18.3431 46 20 C46 21.6569 44.6569 23 43 23 H28 V29 H39 C40.6569 29 42 30.3431 42 32 C42 33.6569 40.6569 35 39 35 H28 V44 C28 45.6569 26.6569 47 25 47 C23.3431 47 22 45.6569 22 44 V17 Z" fill="#ffffff"/>
|
||||
<!-- Subtiele focus stip / accent -->
|
||||
<circle cx="43" cy="43" r="3.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="focizGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="50%" stop-color="#4f46e5"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<filter id="focizShadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4f46e5" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Achtergrond met afgeronde hoeken (superellipse / squircl) -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#focizGrad)" filter="url(#focizShadow)"/>
|
||||
<!-- Subtiele binnenrand highlight -->
|
||||
<rect x="3" y="3" width="58" height="58" rx="15" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- Karakteristieke Fociz 'F' -->
|
||||
<path d="M22 17 H43 C44.6569 17 46 18.3431 46 20 C46 21.6569 44.6569 23 43 23 H28 V29 H39 C40.6569 29 42 30.3431 42 32 C42 33.6569 40.6569 35 39 35 H28 V44 C28 45.6569 26.6569 47 25 47 C23.3431 47 22 45.6569 22 44 V17 Z" fill="#ffffff"/>
|
||||
<!-- Subtiele focus stip / accent -->
|
||||
<circle cx="43" cy="43" r="3.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,9 @@
|
||||
# Beveiliging van de interne includes map (Pen Test Beveiliging)
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz AI Co-Pilot Engine (Release v1.5)
|
||||
* Beheert intelligente assistentie voor Brainstorming en Dagplanning.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/http.php';
|
||||
require_once __DIR__ . '/settings.php';
|
||||
|
||||
/**
|
||||
* STRICT ZERO-DATA LEAKAGE PRIVACY FIREWALL
|
||||
* Garandeert dat NOOIT data uit Jira, Mail (Outlook) of Microsoft Teams
|
||||
* doorgestuurd kan worden naar een externe AI provider (Gemini / OpenAI).
|
||||
*
|
||||
* @param array $messages Referentie naar de messages array die verzonden wordt
|
||||
* @return array Aantal en type geactiveerde firewall filters
|
||||
*/
|
||||
function fociz_ai_enforce_privacy_firewall(array &$messages): array {
|
||||
$detections = [
|
||||
'jira_tickets' => 0,
|
||||
'jira_urls' => 0,
|
||||
'teams_links' => 0,
|
||||
'email_addrs' => 0,
|
||||
'mail_headers' => 0,
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
// Jira ticket keys zoals PROJ-1234, ABC-99 (2 tot 10 hoofdletters gevolgd door streepje en cijfers)
|
||||
'jira_tickets' => '/\b[A-Z]{2,10}-\d+\b/',
|
||||
// Jira / Atlassian domeinen en URLs
|
||||
'jira_urls' => '/https?:\/\/[a-zA-Z0-9-]+\.atlassian\.net[^\s]*/i',
|
||||
// Teams URLs en links
|
||||
'teams_links' => '/https?:\/\/teams\.microsoft\.com[^\s]*/i',
|
||||
// E-mailadressen
|
||||
'email_addrs' => '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/',
|
||||
// E-mailheaders en Microsoft Outlook / Teams traces
|
||||
'mail_headers' => '/\b(Subject:|Onderwerp:|From:|Van:|To:|Aan:|CC:|BCC:|FW:|Fwd:|RE:)\s*[^\r\n]+/i',
|
||||
];
|
||||
|
||||
$replacements = [
|
||||
'jira_tickets' => '[TICKET_AFGESCHERMD]',
|
||||
'jira_urls' => '[JIRA_URL_AFGESCHERMD]',
|
||||
'teams_links' => '[TEAMS_LINK_AFGESCHERMD]',
|
||||
'email_addrs' => '[EMAIL_AFGESCHERMD]',
|
||||
'mail_headers' => '[MAIL_HEADER_AFGESCHERMD]',
|
||||
];
|
||||
|
||||
foreach ($messages as $idx => $msg) {
|
||||
if (!isset($msg['content']) || !is_string($msg['content'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = $msg['content'];
|
||||
foreach ($patterns as $key => $pattern) {
|
||||
$count = 0;
|
||||
$text = preg_replace($pattern, $replacements[$key], $text, -1, $count);
|
||||
if ($count > 0) {
|
||||
$detections[$key] += $count;
|
||||
}
|
||||
}
|
||||
$messages[$idx]['content'] = $text;
|
||||
}
|
||||
|
||||
$totalBlocked = array_sum($detections);
|
||||
if ($totalBlocked > 0) {
|
||||
error_log(sprintf(
|
||||
"[FOCIZ AI FIREWALL] Zero-Data Leakage actief: %d potentiële datalekken geblokkeerd en geanonimiseerd (%s)",
|
||||
$totalBlocked,
|
||||
json_encode($detections)
|
||||
));
|
||||
}
|
||||
|
||||
return $detections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voert een gestructureerde chat completion uit met Google Gemini Pro
|
||||
*
|
||||
* @param array $messages
|
||||
* @param array $options
|
||||
* @param array $aiSettings
|
||||
* @return array
|
||||
*/
|
||||
function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings): array {
|
||||
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
|
||||
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gemini-2.0-flash';
|
||||
$temperature = $options['temperature'] ?? 0.3;
|
||||
$maxTokens = max(512, (int)($options['max_tokens'] ?? 2048));
|
||||
$jsonMode = !empty($options['json_mode']);
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen Google Gemini API-sleutel ingevuld of geconfigureerd.',
|
||||
];
|
||||
}
|
||||
|
||||
// Google Generative Language API structuur opbouwen
|
||||
$systemInstructions = [];
|
||||
$contents = [];
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$role = $msg['role'] ?? 'user';
|
||||
$text = (string)($msg['content'] ?? '');
|
||||
|
||||
if ($role === 'system') {
|
||||
$systemInstructions[] = ['text' => $text];
|
||||
} else {
|
||||
$contents[] = [
|
||||
'role' => ($role === 'assistant') ? 'model' : 'user',
|
||||
'parts' => [['text' => $text]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($contents)) {
|
||||
$contents[] = [
|
||||
'role' => 'user',
|
||||
'parts' => [['text' => 'Hallo']],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'contents' => $contents,
|
||||
'generationConfig' => [
|
||||
'temperature' => (float)$temperature,
|
||||
'maxOutputTokens' => (int)$maxTokens,
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($systemInstructions)) {
|
||||
$payload['system_instruction'] = [
|
||||
'parts' => $systemInstructions,
|
||||
];
|
||||
}
|
||||
|
||||
if ($jsonMode) {
|
||||
$payload['generationConfig']['responseMimeType'] = 'application/json';
|
||||
}
|
||||
|
||||
$cleanModel = preg_replace('#^models/#', '', $model);
|
||||
|
||||
// Helper om tekst uit Gemini candidate te extraheren
|
||||
$extractCandidateText = function(?array $candidate): string {
|
||||
if (!$candidate || empty($candidate['content']['parts'])) {
|
||||
return '';
|
||||
}
|
||||
$txt = '';
|
||||
foreach ($candidate['content']['parts'] as $part) {
|
||||
if (!empty($part['text']) && empty($part['thought'])) {
|
||||
$txt .= $part['text'];
|
||||
}
|
||||
}
|
||||
if ($txt === '' && !empty($candidate['content']['parts'][0]['text'])) {
|
||||
$txt = $candidate['content']['parts'][0]['text'];
|
||||
}
|
||||
return trim($txt);
|
||||
};
|
||||
|
||||
// Normaliseer modelnaam en stel geldige kandidatenlijst op
|
||||
$candidateUrls = [];
|
||||
|
||||
// Helper om endpoints op te bouwen (prioriteer v1beta voor moderne Gemini modellen)
|
||||
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
|
||||
$m = preg_replace('#^models/#', '', trim($m));
|
||||
if ($m === '') return;
|
||||
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
|
||||
if (!in_array($v1beta, $candidateUrls, true)) {
|
||||
$candidateUrls[] = $v1beta;
|
||||
}
|
||||
};
|
||||
|
||||
// Voeg het geconfigureerde model toe als eerste kandidaat (mits niet fictief)
|
||||
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
|
||||
$addCandidatesFor($cleanModel);
|
||||
}
|
||||
|
||||
// Voeg altijd de snelle, hoog-beschikbare Google AI modellen toe als betrouwbare fallbacks
|
||||
$addCandidatesFor('gemini-2.0-flash');
|
||||
$addCandidatesFor('gemini-1.5-flash');
|
||||
$addCandidatesFor('gemini-2.5-flash');
|
||||
$addCandidatesFor('gemini-1.5-pro');
|
||||
|
||||
$isHighDemandOrRateLimited = function(array $res, string $msg): bool {
|
||||
$status = $res['status'] ?? 0;
|
||||
if ($status === 503 || $status === 429) {
|
||||
return true;
|
||||
}
|
||||
$lower = strtolower($msg);
|
||||
return (
|
||||
str_contains($lower, 'high demand') ||
|
||||
str_contains($lower, 'spikes in demand') ||
|
||||
str_contains($lower, 'temporarily unavailable') ||
|
||||
str_contains($lower, 'resource has been exhausted') ||
|
||||
str_contains($lower, 'rate limit') ||
|
||||
str_contains($lower, 'overloaded') ||
|
||||
str_contains($lower, 'too many requests')
|
||||
);
|
||||
};
|
||||
|
||||
$response = null;
|
||||
$lastError = null;
|
||||
$successfulModel = $cleanModel;
|
||||
$finalText = '';
|
||||
|
||||
for ($i = 0; $i < count($candidateUrls); $i++) {
|
||||
$baseUrl = $candidateUrls[$i];
|
||||
$endpoint = $baseUrl . '?key=' . urlencode($apiKey);
|
||||
|
||||
// Doe max 2 pogingen met korte micro-backoff bij een tijdelijke piek (transient spike)
|
||||
for ($attempt = 1; $attempt <= 2; $attempt++) {
|
||||
$res = fociz_http_request($endpoint, [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'x-goog-api-key' => $apiKey,
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 25,
|
||||
]);
|
||||
|
||||
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
|
||||
$extracted = $extractCandidateText($res['json']['candidates'][0]);
|
||||
// Als er tekst is, of finishReason is geldig (bijv. STOP of MAX_TOKENS)
|
||||
if ($extracted !== '' || !empty($res['json']['candidates'][0]['finishReason'])) {
|
||||
$response = $res;
|
||||
$finalText = ($extracted !== '') ? $extracted : 'OK';
|
||||
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
|
||||
$successfulModel = $mName[1];
|
||||
}
|
||||
break 2; // Volledig succes, beëindig beide loops
|
||||
}
|
||||
}
|
||||
|
||||
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
|
||||
$lastError = $msg;
|
||||
|
||||
// Als dit een tijdelijke piek is en we nog een poging over hebben, wacht kort en probeer nogmaals
|
||||
if ($attempt < 2 && $isHighDemandOrRateLimited($res, $msg)) {
|
||||
usleep(800000); // 800ms
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
|
||||
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
|
||||
$recModel = $recMatch[1];
|
||||
$recUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$recModel}:generateContent";
|
||||
if (!in_array($recUrl, $candidateUrls, true)) {
|
||||
$candidateUrls[] = $recUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Model niet gevonden of deprecated -> probeer de volgende kandidaat
|
||||
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. HIGH DEMAND / 503 / 429 OVERBELASTING: NIET STOPPEN!
|
||||
// Schakel direct door naar de volgende fallback kandidaat (bijv. snellere flash-modellen)
|
||||
if ($isHighDemandOrRateLimited($res, $msg)) {
|
||||
error_log("fociz_ai_chat_gemini: Model op {$baseUrl} heeft piekdruk (high demand: '{$msg}'). Schakelt automatisch door naar alternatief model...");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alleen bij expliciete autorisatiefouten (ongeldige API key 400/401/403) stoppen
|
||||
if (in_array($res['status'], [400, 401, 403], true)) {
|
||||
$response = $res;
|
||||
break;
|
||||
}
|
||||
|
||||
// Voor alle andere fouten (serverfout 500, timeout, netwerkdip): probeer volgende kandidaat
|
||||
continue;
|
||||
}
|
||||
|
||||
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
|
||||
if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) {
|
||||
$listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey);
|
||||
$listRes = fociz_http_request($listUrl, [
|
||||
'method' => 'GET',
|
||||
'headers' => ['x-goog-api-key' => $apiKey],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
if ($listRes['status'] === 200 && !empty($listRes['json']['models'])) {
|
||||
$availModels = [];
|
||||
foreach ($listRes['json']['models'] as $mItem) {
|
||||
$mMethods = $mItem['supportedGenerationMethods'] ?? [];
|
||||
if (in_array('generateContent', $mMethods, true)) {
|
||||
$mClean = preg_replace('#^models/#', '', $mItem['name'] ?? '');
|
||||
if ($mClean) $availModels[] = $mClean;
|
||||
}
|
||||
}
|
||||
|
||||
// Zoek een passend gemini model
|
||||
$fallbackModel = null;
|
||||
foreach ($availModels as $am) {
|
||||
if (str_contains($am, 'flash')) {
|
||||
$fallbackModel = $am;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$fallbackModel) {
|
||||
foreach ($availModels as $am) {
|
||||
if (str_contains($am, 'gemini')) {
|
||||
$fallbackModel = $am;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($fallbackModel) {
|
||||
$dynEndpoint = "https://generativelanguage.googleapis.com/v1beta/models/{$fallbackModel}:generateContent?key=" . urlencode($apiKey);
|
||||
$dynRes = fociz_http_request($dynEndpoint, [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'x-goog-api-key' => $apiKey,
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 20,
|
||||
]);
|
||||
|
||||
if ($dynRes['status'] === 200 && !empty($dynRes['json']['candidates'][0])) {
|
||||
$extracted = $extractCandidateText($dynRes['json']['candidates'][0]);
|
||||
if ($extracted !== '' || !empty($dynRes['json']['candidates'][0]['finishReason'])) {
|
||||
$response = $dynRes;
|
||||
$finalText = ($extracted !== '') ? $extracted : 'OK';
|
||||
$successfulModel = $fallbackModel;
|
||||
}
|
||||
} else {
|
||||
$response = $dynRes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$response || $response['status'] !== 200 || $finalText === '') {
|
||||
$errMsg = null;
|
||||
if (!empty($response['json']['error']['message'])) {
|
||||
$errMsg = $response['json']['error']['message'];
|
||||
} elseif (!empty($response['error'])) {
|
||||
$errMsg = $response['error'];
|
||||
} elseif (!empty($response['body'])) {
|
||||
$errMsg = "Gemini fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
|
||||
} else {
|
||||
$errMsg = $lastError ?: "Gemini API aanroep mislukt";
|
||||
}
|
||||
error_log("fociz_ai_chat_gemini fout: {$errMsg}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$rawContent = $finalText;
|
||||
|
||||
// Indien Gemini het als markdown code block wrapped heeft (bv. ```json ... ```), schoon dit op
|
||||
if (preg_match('/^```(?:json)?\s*(.*?)\s*```$/s', $rawContent, $m)) {
|
||||
$cleanContent = trim($m[1]);
|
||||
} else {
|
||||
$cleanContent = $rawContent;
|
||||
}
|
||||
|
||||
$parsedJson = null;
|
||||
if ($jsonMode) {
|
||||
$parsedJson = json_decode($cleanContent, true);
|
||||
if ($parsedJson === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
// Poging 2: json decode met raw
|
||||
$parsedJson = json_decode($rawContent, true);
|
||||
}
|
||||
// Poging 3: extraheer JSON substring {...} of [...] indien AI conversatietekst heeft toegevoegd
|
||||
if ($parsedJson === null) {
|
||||
if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $cleanContent, $matchJson)) {
|
||||
$parsedJson = json_decode($matchJson[0], true);
|
||||
} elseif (preg_match('/\[(?:[^\[\]]|(?R))*\]/s', $cleanContent, $matchJson)) {
|
||||
$parsedJson = json_decode($matchJson[0], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$promptTokens = (int)($response['json']['usageMetadata']['promptTokenCount'] ?? 0);
|
||||
$completionTokens = (int)($response['json']['usageMetadata']['candidatesTokenCount'] ?? 0);
|
||||
if ($promptTokens === 0 && $completionTokens === 0) {
|
||||
// Schatting obv lengte indien niet geretourneerd
|
||||
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
|
||||
$completionTokens = max(1, (int)(strlen($cleanContent) / 4));
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $cleanContent,
|
||||
'json' => $parsedJson,
|
||||
'model' => $successfulModel,
|
||||
'provider' => 'gemini',
|
||||
'prompt_tokens' => $promptTokens,
|
||||
'completion_tokens' => $completionTokens,
|
||||
'total_tokens' => ($promptTokens + $completionTokens),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Voert een gestructureerde chat completion uit met OpenAI
|
||||
*
|
||||
* @param array $messages
|
||||
* @param array $options
|
||||
* @param array $aiSettings
|
||||
* @return array
|
||||
*/
|
||||
function fociz_ai_chat_openai(array $messages, array $options, array $aiSettings): array {
|
||||
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
|
||||
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gpt-4o-mini';
|
||||
$temperature = $options['temperature'] ?? 0.3;
|
||||
$maxTokens = $options['max_tokens'] ?? 1500;
|
||||
$jsonMode = !empty($options['json_mode']);
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen OpenAI API-sleutel ingevuld of geconfigureerd.',
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => $temperature,
|
||||
'max_tokens' => $maxTokens,
|
||||
];
|
||||
|
||||
if ($jsonMode) {
|
||||
$payload['response_format'] = ['type' => 'json_object'];
|
||||
}
|
||||
|
||||
$response = fociz_http_request('https://api.openai.com/v1/chat/completions', [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$apiKey}",
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 20,
|
||||
]);
|
||||
|
||||
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
|
||||
$errMsg = null;
|
||||
if (!empty($response['json']['error']['message'])) {
|
||||
$errMsg = $response['json']['error']['message'];
|
||||
} elseif (!empty($response['error'])) {
|
||||
$errMsg = $response['error'];
|
||||
} elseif (!empty($response['body'])) {
|
||||
$errMsg = "OpenAI fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
|
||||
} else {
|
||||
$errMsg = "OpenAI API aanroep mislukt (HTTP {$response['status']})";
|
||||
}
|
||||
error_log("fociz_ai_chat_openai fout: {$errMsg}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$rawContent = trim($response['json']['choices'][0]['message']['content']);
|
||||
$parsedJson = null;
|
||||
|
||||
if ($jsonMode) {
|
||||
$parsedJson = json_decode($rawContent, true);
|
||||
}
|
||||
|
||||
$promptTokens = (int)($response['json']['usage']['prompt_tokens'] ?? 0);
|
||||
$completionTokens = (int)($response['json']['usage']['completion_tokens'] ?? 0);
|
||||
if ($promptTokens === 0 && $completionTokens === 0) {
|
||||
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
|
||||
$completionTokens = max(1, (int)(strlen($rawContent) / 4));
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $rawContent,
|
||||
'json' => $parsedJson,
|
||||
'model' => $response['json']['model'] ?? $model,
|
||||
'provider' => 'openai',
|
||||
'prompt_tokens' => $promptTokens,
|
||||
'completion_tokens' => $completionTokens,
|
||||
'total_tokens' => ($promptTokens + $completionTokens),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoofdfunctie: Voert een gestructureerde chat completion uit
|
||||
* Routeert automatisch naar Google Gemini Pro of OpenAI en dwingt ALTIJD
|
||||
* de Zero-Data Leakage privacy firewall af.
|
||||
*
|
||||
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
|
||||
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int, 'user_id' => ?int]
|
||||
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string, 'provider' => ?string]
|
||||
*/
|
||||
function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
// 1. ABSOLUTE ZERO-DATA LEAKAGE PRIVACY CONTROLE:
|
||||
// Filter ALLE Jira ticket keys, Atlassian URLs, Teams links en Mail adressen/headers
|
||||
fociz_ai_enforce_privacy_firewall($messages);
|
||||
|
||||
$userId = $options['user_id'] ?? null;
|
||||
$aiSettings = get_ai_settings($userId);
|
||||
|
||||
if (empty($aiSettings['ai_api_key'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen AI API-sleutel geconfigureerd. Stel Google Gemini Pro of OpenAI in via Systeembeheer of je profiel.',
|
||||
];
|
||||
}
|
||||
|
||||
$provider = strtolower($aiSettings['ai_provider'] ?? 'gemini');
|
||||
$model = strtolower($aiSettings['ai_model'] ?? '');
|
||||
|
||||
// Routering: als provider gemini is of model gemini bevat, stuur naar Google Gemini
|
||||
if ($provider === 'gemini' || strpos($model, 'gemini') !== false) {
|
||||
$res = fociz_ai_chat_gemini($messages, $options, $aiSettings);
|
||||
} else {
|
||||
$res = fociz_ai_chat_openai($messages, $options, $aiSettings);
|
||||
}
|
||||
|
||||
// Verbruiksstatistieken registreren bij geslaagde aanroep
|
||||
if (!empty($res['success'])) {
|
||||
$usedProvider = $res['provider'] ?? $provider;
|
||||
$usedModel = $res['model'] ?? $model;
|
||||
$pTokens = (int)($res['prompt_tokens'] ?? 0);
|
||||
$cTokens = (int)($res['completion_tokens'] ?? 0);
|
||||
record_ai_usage($usedProvider, $usedModel, $pTokens, $cTokens, $userId);
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Werkt een brainstorm idee uit tot 3-5 concrete, behapbare items
|
||||
* voor een Kanban Bord, Sprint/Tijdlijn of Dagplanner.
|
||||
*
|
||||
* @param string $ideaContent
|
||||
* @param string $sessionTitle
|
||||
* @param string $theme
|
||||
* @param int|null $userId
|
||||
* @return array ['success' => bool, 'items' => array, 'error' => ?string]
|
||||
*/
|
||||
function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '', string $theme = '', ?int $userId = null): array {
|
||||
$systemPrompt = "Je bent een senior Agile/Scrum product specialist en technisch ontwerper.\n"
|
||||
. "Je taak is om een brainstorm idee of concept om te zetten in 3 tot 5 concrete, behapbare en direct uitvoerbare taken voor een agile team.\n"
|
||||
. "Elk item moet een heldere taak zijn die binnen één dag of sprintblok voltooid kan worden.\n\n"
|
||||
. "Formateer de uitvoer als een JSON-object met exact deze structuur:\n"
|
||||
. "{\n"
|
||||
. " \"summary\": \"Korte samenvatting van de aanpak (1 zin)\",\n"
|
||||
. " \"items\": [\n"
|
||||
. " {\n"
|
||||
. " \"title\": \"Actiegerichte titel (bijv: API endpoint ontwerpen voor authenticatie)\",\n"
|
||||
. " \"description\": \"Korte toelichting en 2 concrete acceptatiecriteria\",\n"
|
||||
. " \"estimated_minutes\": 45,\n"
|
||||
. " \"priority\": \"P1\",\n"
|
||||
. " \"color\": \"indigo\",\n"
|
||||
. " \"category\": \"Backend\"\n"
|
||||
. " }\n"
|
||||
. " ]\n"
|
||||
. "}\n\n"
|
||||
. "Regels:\n"
|
||||
. "- Prioriteit moet 'P1' (hoog/focus), 'P2' (medium) of 'P3' (laag) zijn.\n"
|
||||
. "- Kleur moet gekozen worden uit: 'indigo', 'blue', 'emerald', 'amber', 'rose', 'purple'.\n"
|
||||
. "- Duur in minuten: 15, 30, 45, 60, 90 of 120.\n"
|
||||
. "- Categorie: 'Frontend', 'Backend', 'Design', 'Onderzoek', of 'Testing'.\n"
|
||||
. "- Retourneer UITSCHLUITEND geldige JSON.";
|
||||
|
||||
$userPrompt = "Brainstorm Sessie: " . ($sessionTitle ?: 'Algemeen') . "\n"
|
||||
. "Thema: " . ($theme ?: 'Productontwikkeling') . "\n\n"
|
||||
. "Uit te werken idee:\n\"" . $ideaContent . "\"\n\n"
|
||||
. "Splits dit idee op in 3 tot 5 concrete items.";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], [
|
||||
'json_mode' => true,
|
||||
'temperature' => 0.4,
|
||||
'max_tokens' => 2000,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
|
||||
// Fallback: Als AI na alle modelpogingen niet reageert, creëer 3 logische taken zodat de workflow direct doorloopt
|
||||
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
|
||||
return [
|
||||
'success' => true,
|
||||
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
|
||||
'items' => [
|
||||
[
|
||||
'title' => "Onderzoek & specificatie: {$shortTitle}",
|
||||
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
|
||||
'estimated_minutes' => 45,
|
||||
'priority' => 'P1',
|
||||
'color' => 'indigo',
|
||||
'category' => 'Onderzoek'
|
||||
],
|
||||
[
|
||||
'title' => "Realisatie & uitwerking: {$shortTitle}",
|
||||
'description' => "Implementatie en concrete uitvoering van het idee.",
|
||||
'estimated_minutes' => 90,
|
||||
'priority' => 'P2',
|
||||
'color' => 'blue',
|
||||
'category' => 'Frontend'
|
||||
],
|
||||
[
|
||||
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
|
||||
'description' => "Testen, feedback verwerken en afronden.",
|
||||
'estimated_minutes' => 30,
|
||||
'priority' => 'P2',
|
||||
'color' => 'emerald',
|
||||
'category' => 'Testing'
|
||||
]
|
||||
],
|
||||
'model' => 'Smart Fallback',
|
||||
'is_fallback' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$parsed = $aiRes['json'] ?? null;
|
||||
$items = [];
|
||||
$summary = '';
|
||||
|
||||
if (is_array($parsed)) {
|
||||
if (!empty($parsed['items']) && is_array($parsed['items'])) {
|
||||
$items = $parsed['items'];
|
||||
} elseif (!empty($parsed['tasks']) && is_array($parsed['tasks'])) {
|
||||
$items = $parsed['tasks'];
|
||||
} elseif (!empty($parsed['taken']) && is_array($parsed['taken'])) {
|
||||
$items = $parsed['taken'];
|
||||
} elseif (!empty($parsed['cards']) && is_array($parsed['cards'])) {
|
||||
$items = $parsed['cards'];
|
||||
} elseif (array_is_list($parsed)) {
|
||||
$items = $parsed;
|
||||
}
|
||||
$summary = (string)($parsed['summary'] ?? '');
|
||||
}
|
||||
|
||||
// Als JSON parsing toch leeg was, probeer regex fallback op ruwe content
|
||||
if (empty($items) && !empty($aiRes['content'])) {
|
||||
if (preg_match('/\[\s*\{.*\}\s*\]/s', $aiRes['content'], $mArr)) {
|
||||
$decodedArr = json_decode($mArr[0], true);
|
||||
if (is_array($decodedArr)) {
|
||||
$items = $decodedArr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
|
||||
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
|
||||
return [
|
||||
'success' => true,
|
||||
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
|
||||
'items' => [
|
||||
[
|
||||
'title' => "Onderzoek & specificatie: {$shortTitle}",
|
||||
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
|
||||
'estimated_minutes' => 45,
|
||||
'priority' => 'P1',
|
||||
'color' => 'indigo',
|
||||
'category' => 'Onderzoek'
|
||||
],
|
||||
[
|
||||
'title' => "Realisatie & uitwerking: {$shortTitle}",
|
||||
'description' => "Implementatie en concrete uitvoering van het idee.",
|
||||
'estimated_minutes' => 90,
|
||||
'priority' => 'P2',
|
||||
'color' => 'blue',
|
||||
'category' => 'Frontend'
|
||||
],
|
||||
[
|
||||
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
|
||||
'description' => "Testen, feedback verwerken en afronden.",
|
||||
'estimated_minutes' => 30,
|
||||
'priority' => 'P2',
|
||||
'color' => 'emerald',
|
||||
'category' => 'Testing'
|
||||
]
|
||||
],
|
||||
'model' => 'Smart Fallback',
|
||||
'is_fallback' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'summary' => $summary ?: 'Dit idee is opgesplitst in behapbare deeltaken.',
|
||||
'items' => $items,
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Categoriseert sticky notes automatisch in:
|
||||
* - 'Ideeën'
|
||||
* - 'Knelpunten'
|
||||
* - 'Oplossingen'
|
||||
* - 'Quick Wins'
|
||||
*/
|
||||
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards, ?int $userId = null): array {
|
||||
if (empty($cards)) {
|
||||
return ['success' => true, 'categorized' => []];
|
||||
}
|
||||
|
||||
$cardItems = [];
|
||||
foreach ($cards as $c) {
|
||||
$cardItems[] = [
|
||||
'id' => $c['id'],
|
||||
'content' => $c['content'],
|
||||
'category' => $c['category'] ?? 'Ideeën',
|
||||
];
|
||||
}
|
||||
|
||||
$systemPrompt = "Je bent een deskundige agile facilitator en brainstorm co-pilot voor software- en productontwikkeling.\n"
|
||||
. "Je ontvangt een lijst met brainstorm kaartjes voor de sessie: '{$sessionTitle}' (Thema: '{$theme}').\n"
|
||||
. "Je taak is om elk kaartje in te delen in exact één van deze 4 categorieën:\n"
|
||||
. "1. 'Ideeën' (nieuwe concepten, functies, wensen)\n"
|
||||
. "2. 'Knelpunten' (problemen, blokkades, risico's, frustraties)\n"
|
||||
. "3. 'Oplossingen' (concrete antwoorden op knelpunten, architectuurkeuzes)\n"
|
||||
. "4. 'Quick Wins' (kleine, makkelijk uitvoerbare verbeteringen met hoge impact)\n\n"
|
||||
. "Retourneer uitsluitend een JSON-object met het veld 'categorized' dat een array is van objecten met 'id' en 'category'.";
|
||||
|
||||
$userPrompt = "Hier zijn de kaartjes:\n" . json_encode($cardItems, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'categorized' => $aiRes['json']['categorized']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
|
||||
*/
|
||||
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4, ?int $userId = null, string $description = ''): array {
|
||||
$descContext = !empty($description) ? "\nContext en briefing van de sessie:\n" . trim($description) . "\n" : "";
|
||||
|
||||
$systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n"
|
||||
. "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n"
|
||||
. $descContext
|
||||
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een post-it staan).\n"
|
||||
. "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is.";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën op basis van de sessie en context."],
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
|
||||
$ideas = [
|
||||
"Gebruikerservaring en flow optimaliseren voor {$sessionTitle}",
|
||||
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
|
||||
"Koppelingen en integraties verkennen voor snellere afhandeling",
|
||||
"Feedback- en validatielus inrichten met eindgebruikers"
|
||||
];
|
||||
return ['success' => true, 'ideas' => array_slice($ideas, 0, max(2, $count)), 'is_fallback' => true];
|
||||
}
|
||||
|
||||
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyseert de volledige sessie briefing en genereert een complete set post-its verdeeld over:
|
||||
* - Ideeën & Concepten (amber)
|
||||
* - Knelpunten & Uitdagingen (rose)
|
||||
* - Oplossingen & Features (teal)
|
||||
* - Quick Wins (indigo)
|
||||
*/
|
||||
function fociz_ai_generate_session_board(string $sessionTitle, string $theme, string $description = '', ?int $userId = null): array {
|
||||
$descContext = !empty($description)
|
||||
? "Uitgebreide sessiebeschrijving en briefing:\n" . trim($description) . "\n\n"
|
||||
: "Geen aanvullende briefing opgegeven; baseer je op de titel en het thema.\n\n";
|
||||
|
||||
$systemPrompt = "Je bent een ervaren agile facilitator en strategisch adviseur.\n"
|
||||
. "Je analyseert de briefing van een brainstormsessie getiteld '{$sessionTitle}' (Thema: '{$theme}').\n"
|
||||
. $descContext
|
||||
. "Jouw taak is om de sessie direct uit te werken tot een rijk, concreet whiteboard met post-its over exact 4 categorieën:\n"
|
||||
. "1. 'Ideeën': Vernieuwende concepten, functionaliteiten en strategische kansen (2 tot 4 post-its).\n"
|
||||
. "2. 'Knelpunten': Concreet benoemde knelpunten, risico's, blokkades of gebruikersfrustraties (2 tot 4 post-its).\n"
|
||||
. "3. 'Oplossingen': Structurele oplossingen, architectuurkeuzes of procesaanpassingen voor de knelpunten (2 tot 4 post-its).\n"
|
||||
. "4. 'Quick Wins': Laagdrempelige acties die met minimale inspanning direct resultaat opleveren (2 tot 3 post-its).\n\n"
|
||||
. "Regels per post-it:\n"
|
||||
. "- Formuleer scherp, helder en direct to-the-point (1-2 zinnen per kaartje alsof het op een fysieke post-it staat).\n"
|
||||
. "- Sluit naadloos aan op de verstrekte briefing en achtergrondinformatie.\n\n"
|
||||
. "Retourneer UITSLUITEND een geldig JSON-object met het veld 'cards', waarbij elk item bestaat uit:\n"
|
||||
. "- 'content': string (de tekst op de post-it)\n"
|
||||
. "- 'category': exact één van ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins']";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."],
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['cards']) || !is_array($aiRes['json']['cards'])) {
|
||||
return [
|
||||
'success' => true,
|
||||
'cards' => [
|
||||
['content' => "Nieuwe visie en kernfunctionaliteit uitwerken voor {$sessionTitle}", 'category' => 'Ideeën'],
|
||||
['content' => "Gebruikerservaring en self-service flow versterken", 'category' => 'Ideeën'],
|
||||
['content' => "Onduidelijke randvoorwaarden of ontbrekende specificaties", 'category' => 'Knelpunten'],
|
||||
['content' => "Handmatige stappen die vertraging en overbelasting veroorzaken", 'category' => 'Knelpunten'],
|
||||
['content' => "Geautomatiseerd proces en herbruikbare templates inrichten", 'category' => 'Oplossingen'],
|
||||
['content' => "Duidelijke acceptatiecriteria en testvalidatie vooraf", 'category' => 'Oplossingen'],
|
||||
['content' => "Quick win: Korte validatiesessie organiseren met het team", 'category' => 'Quick Wins'],
|
||||
['content' => "Quick win: Dashboard overzicht en checklist direct klaarzetten", 'category' => 'Quick Wins'],
|
||||
],
|
||||
'is_fallback' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'cards' => $aiRes['json']['cards']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zet brainstorm kaartjes om in een scherp Roadmap Item
|
||||
*/
|
||||
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '', ?int $userId = null): array {
|
||||
$systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n"
|
||||
. "Retourneer een JSON-object met:\n"
|
||||
. "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n"
|
||||
. "- 'description': Een duidelijke beschrijving van wat er gebouwd moet worden inclusief gebruikerswaarde en 2-3 acceptatiecriteria\n"
|
||||
. "- 'priority': 'HIGH', 'MEDIUM' of 'LOW'\n"
|
||||
. "- 'type': 'FEATURE', 'IMPROVEMENT' of 'TECH_DEBT'\n"
|
||||
. "- 'quarter': 'Q3' of 'Q4'";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"],
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
|
||||
// Intelligente fallback bij storing / high demand zodat roadmap conversie nooit faalt
|
||||
$lines = explode("\n", trim($content));
|
||||
$firstLine = trim($lines[0] ?? 'Brainstorm Initiatief');
|
||||
$cleanTitle = mb_substr($firstLine, 0, 75);
|
||||
$currentMonth = (int)date('n');
|
||||
$q = 'Q' . ceil($currentMonth / 3);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'item' => [
|
||||
'title' => $cleanTitle,
|
||||
'description' => $content,
|
||||
'priority' => 'HIGH',
|
||||
'type' => 'FEATURE',
|
||||
'quarter' => $q,
|
||||
],
|
||||
'is_fallback' => true,
|
||||
'notice' => 'AI model had tijdelijke piekdruk; metadata automatisch ingevuld.'
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'item' => $aiRes['json']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berekent een slimme dagindeling voor open taken rondom geanonimiseerde bezette tijdblokken.
|
||||
* ZERO-DATA LEAKAGE: Bevat NOOIT meetingtitels, inhoud of e-mailadressen!
|
||||
*/
|
||||
function fociz_ai_smart_schedule(array $tasks, array $busyBlocks, array $workdaySettings, ?int $userId = null): array {
|
||||
$startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0];
|
||||
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:00')[0];
|
||||
$lunchStart = $workdaySettings['lunch_start'] ?? '12:30';
|
||||
$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00';
|
||||
|
||||
// Anonimiseer taken: stuur alleen ID, titel (gefilterd door firewall), prioriteit en duur
|
||||
$sanitizedTasks = array_map(fn($t) => [
|
||||
'id' => (int)$t['id'],
|
||||
'title' => (string)$t['title'],
|
||||
'priority' => $t['priority'] ?? 'P2',
|
||||
'duration_minutes' => (int)($t['duration_minutes'] ?? 30),
|
||||
], $tasks);
|
||||
|
||||
// Anonimiseer bezette blokken: GEEN titels of deelnemers, enkel tijdstippen!
|
||||
$sanitizedBusy = array_map(fn($b) => [
|
||||
'start' => $b['start'] ?? '09:00',
|
||||
'end' => $b['end'] ?? '10:00',
|
||||
], $busyBlocks);
|
||||
|
||||
$systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n"
|
||||
. "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n"
|
||||
. "Regels:\n"
|
||||
. "1. Plan GEEN taken tijdens bezette blokken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
|
||||
. "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n"
|
||||
. "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n"
|
||||
. "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}.";
|
||||
|
||||
$userPrompt = "Bezette tijdsintervallen (agenda):\n" . json_encode($sanitizedBusy, JSON_UNESCAPED_UNICODE) . "\n\n"
|
||||
. "In te plannen taken:\n" . json_encode($sanitizedTasks, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een persoonlijke AI dagbriefing op basis van interne Fociz taken.
|
||||
* ZERO-DATA LEAKAGE: Absoluut GEEN Jira, Mail (Outlook) of Teams data!
|
||||
*
|
||||
* @param string $userName Naam van de gebruiker
|
||||
* @param string $date Datum (Y-m-d)
|
||||
* @param array $tasks Openstaande interne taken van vandaag
|
||||
* @param array $busyBlocks Geanonimiseerde bezette tijdsblokken (start/end)
|
||||
* @param array $unfinishedYest Gisteren niet voltooide taken
|
||||
* @param int|null $userId
|
||||
* @return array ['success' => bool, 'greeting' => string, 'focus_task' => string, 'tip' => string, 'warnings' => array]
|
||||
*/
|
||||
function fociz_ai_daily_briefing(
|
||||
string $userName,
|
||||
string $date,
|
||||
array $tasks,
|
||||
array $busyBlocks = [],
|
||||
array $unfinishedYest = [],
|
||||
?int $userId = null
|
||||
): array {
|
||||
$dutchDays = ['', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
|
||||
$dayName = $dutchDays[(int)date('N', strtotime($date))] ?? 'vandaag';
|
||||
$dateLabel = $dayName . ' ' . date('j', strtotime($date)) . ' ' . date('F', strtotime($date));
|
||||
|
||||
// Alleen pure Fociz taken samenvatten
|
||||
$taskSummary = array_map(fn($t) => [
|
||||
'title' => $t['title'],
|
||||
'priority' => $t['priority'] ?? 'P2',
|
||||
'time' => $t['time_block_start'] ?? null,
|
||||
'duration' => (int)($t['duration_minutes'] ?? 30),
|
||||
], array_slice($tasks, 0, 10));
|
||||
|
||||
$yesterdaySummary = array_map(fn($t) => $t['title'], array_slice($unfinishedYest, 0, 5));
|
||||
|
||||
$systemPrompt = "Je bent de persoonlijke AI Co-Pilot van {$userName} in Fociz. "
|
||||
. "Schrijf een korte, energieke en persoonlijke dagbriefing in het Nederlands voor {$dateLabel}. "
|
||||
. "Wees direct, concreet en vriendelijk. Gebruik geen markdown headers, geen bullet symbolen zoals *, -, •. "
|
||||
. "Geef 1-2 zinnen dagopening + 1 zin over de grootste prioriteit + 1 praktische focustip. "
|
||||
. "Maximaal 60 woorden totaal. "
|
||||
. "Retourneer een JSON-object met:\n"
|
||||
. "- 'greeting': openingszin (max 20 woorden)\n"
|
||||
. "- 'focus_task': de meest urgente taak vandaag (max 15 woorden)\n"
|
||||
. "- 'tip': één concrete focustip voor vandaag (max 20 woorden)\n"
|
||||
. "- 'warnings': array van strings, max 2 items (bijv. gisteren niet afgerond of overvolle dag)";
|
||||
|
||||
$userPrompt = json_encode([
|
||||
'date' => $dateLabel,
|
||||
'tasks_today' => $taskSummary,
|
||||
'busy_blocks_count' => count($busyBlocks),
|
||||
'unfinished_yesterday' => $yesterdaySummary,
|
||||
'total_tasks' => count($tasks),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true, 'temperature' => 0.6, 'max_tokens' => 300, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen dagbriefing genereren'];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'greeting' => $aiRes['json']['greeting'] ?? "Goedemorgen {$userName}, een nieuwe dag!",
|
||||
'focus_task' => $aiRes['json']['focus_task'] ?? '',
|
||||
'tip' => $aiRes['json']['tip'] ?? '',
|
||||
'warnings' => $aiRes['json']['warnings'] ?? [],
|
||||
'model' => $aiRes['model'] ?? 'AI',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Board & Timeline Integratie Helper
|
||||
* Biedt functies om taken / e-mails / Jira tickets direct als kaarten toe te voegen aan Kanban-borden en Project Tijdlijnen.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
|
||||
/**
|
||||
* Haalt alle actieve borden op waar de gebruiker toegang toe heeft, inclusief kolommen (lanes).
|
||||
*/
|
||||
function fociz_get_user_boards_with_lanes(PDO $db, int $userId, bool $isAdmin): array {
|
||||
if ($isAdmin) {
|
||||
$stmt = $db->query("
|
||||
SELECT b.id, b.title, b.description, b.created_by_id
|
||||
FROM boards b
|
||||
WHERE b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$boards = $stmt->fetchAll();
|
||||
} else {
|
||||
$stmt = $db->prepare("
|
||||
SELECT b.id, b.title, b.description, b.created_by_id
|
||||
FROM boards b
|
||||
JOIN board_members bm ON b.id = bm.board_id
|
||||
WHERE bm.user_id = ? AND b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$boards = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
if (empty($boards)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$boardIds = array_map(fn($b) => (int)$b['id'], $boards);
|
||||
$placeholders = implode(',', array_fill(0, count($boardIds), '?'));
|
||||
|
||||
$laneStmt = $db->prepare("
|
||||
SELECT id, board_id, title, color, position
|
||||
FROM board_lanes
|
||||
WHERE board_id IN ({$placeholders})
|
||||
ORDER BY position ASC, id ASC
|
||||
");
|
||||
$laneStmt->execute($boardIds);
|
||||
$lanes = $laneStmt->fetchAll();
|
||||
|
||||
$lanesByBoard = [];
|
||||
foreach ($lanes as $lane) {
|
||||
$bId = (int)$lane['board_id'];
|
||||
$lanesByBoard[$bId][] = $lane;
|
||||
}
|
||||
|
||||
foreach ($boards as &$b) {
|
||||
$bId = (int)$b['id'];
|
||||
$b['lanes'] = $lanesByBoard[$bId] ?? [];
|
||||
}
|
||||
unset($b);
|
||||
|
||||
return $boards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voegt een item toe aan een Kanban bord en optioneel aan de Project Tijdlijn.
|
||||
*
|
||||
* @param PDO $db Database verbinding
|
||||
* @param int $userId ID van de gebruiker
|
||||
* @param bool $isAdmin Is gebruiker admin
|
||||
* @param array $data Data array met:
|
||||
* - board_id (int)
|
||||
* - lane_id (int, optioneel - defaults naar 1e lane)
|
||||
* - title (string)
|
||||
* - description (string, optioneel)
|
||||
* - priority ('P1'|'P2'|'P3', optioneel)
|
||||
* - color (string, optioneel)
|
||||
* - schedule_on_timeline (bool, optioneel)
|
||||
* - start_date (string 'YYYY-MM-DD', optioneel)
|
||||
* - end_date (string 'YYYY-MM-DD', optioneel)
|
||||
* - start_time (string 'HH:MM', optioneel)
|
||||
* - end_time (string 'HH:MM', optioneel)
|
||||
* - sync_outlook (bool, optioneel)
|
||||
* @return array ['success' => bool, 'card_id' => int, 'message' => string]
|
||||
*/
|
||||
function fociz_add_item_to_board_and_timeline(PDO $db, int $userId, bool $isAdmin, array $data): array {
|
||||
$boardId = (int)($data['board_id'] ?? 0);
|
||||
$title = trim($data['title'] ?? '');
|
||||
if ($boardId <= 0 || empty($title)) {
|
||||
return ['success' => false, 'error' => 'Geldig bord en titel zijn verplicht.'];
|
||||
}
|
||||
|
||||
// Autorisatiecheck
|
||||
if (!$isAdmin) {
|
||||
$stmtCheck = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmtCheck->execute([$boardId, $userId]);
|
||||
if ((int)$stmtCheck->fetchColumn() === 0) {
|
||||
return ['success' => false, 'error' => 'Geen toegang tot dit bord.'];
|
||||
}
|
||||
}
|
||||
|
||||
$laneId = (int)($data['lane_id'] ?? 0);
|
||||
if ($laneId <= 0) {
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
|
||||
$stmtLane->execute([$boardId]);
|
||||
$laneId = (int)$stmtLane->fetchColumn();
|
||||
}
|
||||
|
||||
if ($laneId <= 0) {
|
||||
return ['success' => false, 'error' => 'Het geselecteerde bord heeft geen kolommen.'];
|
||||
}
|
||||
|
||||
$description = trim($data['description'] ?? '');
|
||||
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
|
||||
$color = in_array($data['color'] ?? '', ['yellow', 'blue', 'green', 'purple', 'pink', 'orange', 'slate', 'indigo']) ? $data['color'] : 'blue';
|
||||
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
|
||||
$stmtInsert = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmtInsert->execute([$boardId, $laneId, $title, $description, $userId, $priority, $color, $maxPos + 1, $userId]);
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
|
||||
// Optioneel inplannen op Tijdlijn / Gantt
|
||||
$scheduleOnTimeline = !empty($data['schedule_on_timeline']);
|
||||
if ($scheduleOnTimeline && $cardId > 0) {
|
||||
$startDate = !empty($data['start_date']) ? trim($data['start_date']) : date('Y-m-d');
|
||||
$endDate = !empty($data['end_date']) ? trim($data['end_date']) : $startDate;
|
||||
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : '09:00';
|
||||
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : '17:00';
|
||||
$daysOfWeek = trim($data['days_of_week'] ?? '1,2,3,4,5');
|
||||
|
||||
$stmtSched = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
|
||||
VALUES (?, 'DAILY_RANGE', ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
start_date = VALUES(start_date),
|
||||
end_date = VALUES(end_date),
|
||||
start_time = VALUES(start_time),
|
||||
end_time = VALUES(end_time),
|
||||
days_of_week = VALUES(days_of_week)
|
||||
");
|
||||
$stmtSched->execute([$cardId, $startDate, $endDate, $startTime, $endTime, $daysOfWeek]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'board_id' => $boardId,
|
||||
'lane_id' => $laneId,
|
||||
'scheduled_on_timeline' => $scheduleOnTimeline,
|
||||
'message' => $scheduleOnTimeline
|
||||
? 'Item succesvol toegevoegd aan het Bord en ingepland op de Tijdlijn!'
|
||||
: 'Item succesvol toegevoegd aan het Bord!'
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
/**
|
||||
* Centrale Caching Laag voor Fociz
|
||||
* Biedt een supersnelle database- én in-memory transiënte cache voor externe API-aanroepen (Microsoft Graph, Jira Cloud).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
function ensure_app_cache_table(?PDO $db = null): void {
|
||||
static $ensured = false;
|
||||
if ($ensured) return;
|
||||
|
||||
try {
|
||||
$pdo = $db ?: get_db_connection();
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS `app_cache` (
|
||||
`cache_key` VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
`cache_value` MEDIUMTEXT NOT NULL,
|
||||
`expires_at` INT UNSIGNED NOT NULL,
|
||||
INDEX `idx_app_cache_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
");
|
||||
$ensured = true;
|
||||
} catch (Throwable $e) {
|
||||
error_log("ensure_app_cache_table error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt een item op uit de cache.
|
||||
* Controleert eerst het runtime statisch geheugen, vervolgens MariaDB.
|
||||
*/
|
||||
function fociz_cache_get(string $key, $default = null) {
|
||||
static $memoryCache = [];
|
||||
|
||||
$now = time();
|
||||
|
||||
// 1. Runtime memory check (0ms)
|
||||
if (isset($memoryCache[$key])) {
|
||||
if ($memoryCache[$key]['expires_at'] > $now) {
|
||||
return $memoryCache[$key]['value'];
|
||||
}
|
||||
unset($memoryCache[$key]);
|
||||
}
|
||||
|
||||
// 2. Database check (< 0.5ms)
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_app_cache_table($db);
|
||||
|
||||
$stmt = $db->prepare("SELECT cache_value, expires_at FROM app_cache WHERE cache_key = ?");
|
||||
$stmt->execute([$key]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row) {
|
||||
if ((int)$row['expires_at'] > $now) {
|
||||
$decoded = json_decode($row['cache_value'], true);
|
||||
$val = ($decoded !== null || $row['cache_value'] === 'null') ? $decoded : $row['cache_value'];
|
||||
$memoryCache[$key] = [
|
||||
'value' => $val,
|
||||
'expires_at' => (int)$row['expires_at']
|
||||
];
|
||||
return $val;
|
||||
} else {
|
||||
// Verlopen item direct opruimen
|
||||
$del = $db->prepare("DELETE FROM app_cache WHERE cache_key = ?");
|
||||
$del->execute([$key]);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_cache_get error for {$key}: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slaat een item op in de cache met een bepaalde levensduur in seconden.
|
||||
*/
|
||||
function fociz_cache_set(string $key, $value, int $ttlSeconds = 60): bool {
|
||||
static $memoryCache = [];
|
||||
|
||||
$now = time();
|
||||
$expiresAt = $now + max(1, $ttlSeconds);
|
||||
|
||||
// 1. Sla op in runtime memory
|
||||
$memoryCache[$key] = [
|
||||
'value' => $value,
|
||||
'expires_at' => $expiresAt
|
||||
];
|
||||
|
||||
// 2. Sla op in database
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_app_cache_table($db);
|
||||
|
||||
$valToStore = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO app_cache (cache_key, cache_value, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
cache_value = VALUES(cache_value),
|
||||
expires_at = VALUES(expires_at)
|
||||
");
|
||||
return $stmt->execute([$key, $valToStore, $expiresAt]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_cache_set error for {$key}: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert een specifiek item uit de cache.
|
||||
*/
|
||||
function fociz_cache_delete(string $key): bool {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_app_cache_table($db);
|
||||
$stmt = $db->prepare("DELETE FROM app_cache WHERE cache_key = ?");
|
||||
return $stmt->execute([$key]);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert alle items met een bepaald voorvoegsel (bijv. 'ms_events_1_')
|
||||
*/
|
||||
function fociz_cache_delete_pattern(string $prefix): bool {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_app_cache_table($db);
|
||||
$stmt = $db->prepare("DELETE FROM app_cache WHERE cache_key LIKE ?");
|
||||
return $stmt->execute([$prefix . '%']);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert alle verlopen items actief uit de cache tabel (AVG/GDPR opschoning)
|
||||
*/
|
||||
function fociz_cache_cleanup_expired(?PDO $db = null): int {
|
||||
try {
|
||||
$pdo = $db ?: get_db_connection();
|
||||
ensure_app_cache_table($pdo);
|
||||
$stmt = $pdo->prepare("DELETE FROM app_cache WHERE expires_at < ?");
|
||||
$stmt->execute([time()]);
|
||||
return $stmt->rowCount();
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_cache_cleanup_expired error: " . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Probabilistische automatische GC (5% kans bij elke script execution)
|
||||
if (mt_rand(1, 20) === 1) {
|
||||
fociz_cache_cleanup_expired();
|
||||
}
|
||||
@@ -14,32 +14,28 @@
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
// Standaard waarde voor de main branch: 'live' (kan overschreven worden via APP_ENV)
|
||||
// Standaard waarde voor de main branch: 'live' (bepaald via centrale config.php)
|
||||
$environment = defined('APP_ENV') ? APP_ENV : 'live';
|
||||
|
||||
if (function_exists('fociz_env') && fociz_env('APP_ENV')) {
|
||||
$environment = fociz_env('APP_ENV');
|
||||
}
|
||||
|
||||
$isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']);
|
||||
?>
|
||||
|
||||
<!-- FLASHY OMGEVINGSINDICATOR LINKS BOVENIN -->
|
||||
<div id="fociz-env-badge" class="fixed top-2.5 left-2.5 z-[9999] pointer-events-auto select-none transition-all duration-300 hover:scale-105">
|
||||
<?php if ($isLive): ?>
|
||||
<!-- LIVE OMGEVING (GROEN) -->
|
||||
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/95 border border-emerald-500/70 shadow-lg shadow-emerald-500/25 backdrop-blur-md group">
|
||||
<!-- Pulsing Green Radar Indicator -->
|
||||
<span class="relative flex h-2.5 w-2.5">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-80"></span>
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500 shadow-sm shadow-emerald-500"></span>
|
||||
<!-- LIVE OMGEVING (TEAL) -->
|
||||
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/90 border border-teal-500/50 shadow-xs backdrop-blur-md group">
|
||||
<!-- Pulsing Teal Radar Indicator -->
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-teal-500"></span>
|
||||
</span>
|
||||
|
||||
<span class="text-[11px] font-black tracking-wider text-emerald-400 uppercase flex items-center space-x-1">
|
||||
<span class="text-[10px] font-bold tracking-wider text-teal-400 uppercase flex items-center space-x-1">
|
||||
<span>Live</span>
|
||||
</span>
|
||||
|
||||
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.2 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.5 rounded bg-teal-500/15 text-teal-300 border border-teal-500/30">
|
||||
MAIN
|
||||
</span>
|
||||
</div>
|
||||
@@ -56,7 +52,7 @@ $isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod'
|
||||
<span>Development</span>
|
||||
</span>
|
||||
|
||||
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.2 rounded bg-red-500/20 text-red-300 border border-red-500/30">
|
||||
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.5 rounded bg-red-500/20 text-red-300 border border-red-500/30">
|
||||
DEV
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
</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">
|
||||
<footer class="border-t border-slate-200/80 dark:border-slate-800/60 bg-white/40 dark:bg-transparent backdrop-blur-sm py-5 text-center text-xs text-slate-500 transition">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 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 class="font-bold text-slate-800 dark:text-slate-300">Fociz</span>
|
||||
<span>•</span>
|
||||
<span>Focus & Structuur Systeem</span>
|
||||
<span class="text-slate-500 dark:text-slate-400">Focus & Structuur Systeem</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
|
||||
<span class="text-slate-400 dark:text-slate-500">Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -16,6 +16,49 @@
|
||||
<!-- Fociz Agenda Notificaties Engine -->
|
||||
<script src="/assets/js/notifications.js"></script>
|
||||
|
||||
<?php
|
||||
$sessionIdleTimeout = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
?>
|
||||
<!-- Fociz Inactiviteitswaarschuwing Modal (ISO 27001 / NIST SP 800-63B) -->
|
||||
<div id="focizSessionModal" class="fixed inset-0 z-[9999] bg-slate-950/80 backdrop-blur-md hidden items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-[#111622] border border-amber-500/30 dark:border-amber-500/30 rounded-3xl w-full max-w-md shadow-2xl p-6 sm:p-7 text-center relative overflow-hidden">
|
||||
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 dark:bg-amber-500/20 border border-amber-500/30 text-amber-600 dark:text-amber-400 flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-7 h-7 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight mb-2">
|
||||
Sessie verloopt bijna
|
||||
</h3>
|
||||
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-5 leading-relaxed">
|
||||
Wegens inactiviteit wordt u binnenkort automatisch afgemeld conform het beveiligingsbeleid. Klik op <strong class="text-slate-800 dark:text-slate-200">Blijf Ingelogd</strong> om uw sessie te verlengen.
|
||||
</p>
|
||||
|
||||
<div class="inline-flex items-center space-x-2 px-4 py-2 rounded-2xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-700 dark:text-amber-300 font-mono text-xs font-bold mb-6">
|
||||
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke-width="2"></circle>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
<span>Verloopt over:</span>
|
||||
<span id="focizSessionCountdown" class="text-amber-600 dark:text-amber-400 font-black tracking-wider">02:00</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button type="button" id="focizSessionLogoutBtn" class="w-full py-2.5 px-4 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold transition cursor-pointer">
|
||||
Nu Uitloggen
|
||||
</button>
|
||||
<button type="button" id="focizSessionKeepaliveBtn" class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
|
||||
Blijf Ingelogd
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fociz Session Guard Script (NIST SP 800-63B / ISO 27001) -->
|
||||
<script src="/assets/js/session-guard.js" data-timeout="<?= $sessionIdleTimeout ?>"></script>
|
||||
|
||||
<script>
|
||||
// Lucide Icons initialiseren
|
||||
if (typeof lucide !== 'undefined') {
|
||||
@@ -24,4 +67,3 @@
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1,18 +1,47 @@
|
||||
<?php
|
||||
/**
|
||||
* Header & Navigatiebalk voor Fociz
|
||||
* Header & Navigatiebalk voor Fociz (Dual-Mode: Dark & Light, Executive Styling)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
$currentUser = $currentUser ?? require_auth();
|
||||
$currentPage = basename($_SERVER['PHP_SELF']);
|
||||
$currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark';
|
||||
|
||||
// Geef direct het PHP sessieslot vrij voor getoonde pagina's zodat parallelle requests en navigatie nooit blokkeren
|
||||
if (session_status() === PHP_SESSION_ACTIVE && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
|
||||
session_write_close();
|
||||
}
|
||||
?>
|
||||
<!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>
|
||||
<title><?= htmlspecialchars($pageTitle ?? 'Fociz — Focus, Planning & Roadmap') ?></title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<!-- Anti-Flicker Theme Script: direct in head zodat er nooit een flits optreedt -->
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
const serverTheme = <?= json_encode($currentUserTheme ?? 'dark') ?>;
|
||||
const savedTheme = localStorage.getItem('fociz_theme') || serverTheme || 'dark';
|
||||
let effective = savedTheme;
|
||||
if (savedTheme === 'system') {
|
||||
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
if (effective === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
@@ -22,10 +51,12 @@ $currentPage = basename($_SERVER['PHP_SELF']);
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#f0fdf4',
|
||||
500: '#10b981',
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,123 +65,325 @@ $currentPage = basename($_SERVER['PHP_SELF']);
|
||||
</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;
|
||||
<!-- Dagsessie Expiratie Watchdog: reset iedere nacht om 23:59:59 en dwingt dagelijks opnieuw inloggen af -->
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
let hasRedirected = false;
|
||||
const sessionExpiresAt = <?= strtotime('today 23:59:59') ?> * 1000;
|
||||
const checkExpiry = function() {
|
||||
if (hasRedirected) return;
|
||||
if (Date.now() > sessionExpiresAt + 1000) {
|
||||
hasRedirected = true;
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
};
|
||||
|
||||
const msUntilExpiry = sessionExpiresAt - Date.now();
|
||||
if (msUntilExpiry > 0) {
|
||||
setTimeout(checkExpiry, msUntilExpiry + 500);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkExpiry();
|
||||
}
|
||||
});
|
||||
window.addEventListener('focus', checkExpiry);
|
||||
setInterval(checkExpiry, 60000);
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
html.dark body {
|
||||
background-color: #0c0f17;
|
||||
color: #f8fafc;
|
||||
}
|
||||
html:not(.dark) body {
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
html.dark ::-webkit-scrollbar-track { background: #0c0f17; }
|
||||
html.dark ::-webkit-scrollbar-thumb { background: #1e2638; border-radius: 4px; }
|
||||
html.dark ::-webkit-scrollbar-thumb:hover { background: #2d3852; }
|
||||
html:not(.dark) ::-webkit-scrollbar-track { background: #f8fafc; }
|
||||
html:not(.dark) ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
html:not(.dark) ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
/* Modern Block & Card Design System */
|
||||
.fociz-card, .fociz-card-interactive {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.04);
|
||||
border-radius: 1rem;
|
||||
backdrop-filter: blur(12px);
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
html.dark .fociz-card, html.dark .fociz-card-interactive {
|
||||
background: rgba(17, 22, 34, 0.88);
|
||||
border: 1px solid rgba(30, 41, 59, 0.7);
|
||||
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.fociz-card-interactive {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.fociz-card-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.06);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
html.dark .fociz-card-interactive:hover {
|
||||
box-shadow: 0 12px 28px -5px rgba(0, 0, 0, 0.5), 0 0 20px rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
::-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>
|
||||
<body class="min-h-screen flex flex-col antialiased selection:bg-emerald-500 selection:text-white">
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white transition-colors duration-150">
|
||||
|
||||
<?php
|
||||
// Include flashy omgevingsindicator (Dev = Rood, Live = Groen)
|
||||
// Include omgevingsindicator (Dev = Rood, Live = Groen)
|
||||
require_once __DIR__ . '/env_badge.php';
|
||||
?>
|
||||
|
||||
<?php if ($currentUser): ?>
|
||||
<header class="sticky top-0 z-40 bg-[#0c121e]/90 backdrop-blur-md border-b border-slate-800/80">
|
||||
<header class="sticky top-0 z-40 bg-white/80 dark:bg-[#0c0f17]/85 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800/60 shadow-xs transition-colors duration-150">
|
||||
<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 (met linker padding zodat het badge vrij staat) -->
|
||||
<div class="flex items-center space-x-6 pl-28 sm:pl-36">
|
||||
<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">
|
||||
<!-- Brand Logo (uitgelijnd met de widgets / content container) -->
|
||||
<div class="flex items-center space-x-4 lg:space-x-6 shrink-0">
|
||||
<a href="/planner.php" class="flex items-center space-x-2.5 group shrink-0">
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 flex items-center justify-center text-white font-black text-sm shadow-sm shadow-indigo-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>
|
||||
<span class="text-lg font-bold tracking-tight text-slate-900 dark:text-white leading-none">Fociz</span>
|
||||
<span class="text-[9px] text-indigo-600 dark:text-indigo-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 href="/planner.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'planner.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
|
||||
<span>Fociz</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' ?>">
|
||||
|
||||
<a href="/mail.php" class="relative flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'mail.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="mail" class="w-4 h-4 text-sky-500"></i>
|
||||
<span>Mail</span>
|
||||
<span id="navMailBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-black bg-sky-500 text-white flex items-center justify-center leading-none ring-2 ring-white dark:ring-[#0c0f17] shadow-xs pointer-events-none"></span>
|
||||
</a>
|
||||
|
||||
<!-- Dropdown: Projecten (Bord, Tijdlijn, Brainstorm, Roadmap) -->
|
||||
<?php
|
||||
$isProjectsActive = in_array($currentPage, ['board.php', 'timeline.php', 'brainstorm.php', 'brainstorm_canvas.php', 'roadmap.php']);
|
||||
?>
|
||||
<div class="relative group/projects">
|
||||
<button type="button" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition cursor-pointer <?= $isProjectsActive ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="folder-kanban" class="w-4 h-4"></i>
|
||||
<span>Projecten</span>
|
||||
<i data-lucide="chevron-down" class="w-3.5 h-3.5 text-slate-400 transition-transform duration-200 group-hover/projects:rotate-180"></i>
|
||||
</button>
|
||||
<div class="absolute left-0 top-full pt-2 w-64 hidden group-hover/projects:block z-50 animate-in fade-in zoom-in-95 duration-150">
|
||||
<div class="bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-xl p-2 space-y-1 backdrop-blur-md">
|
||||
<!-- 1. Bord (Kanban) -->
|
||||
<a href="/board.php" class="flex items-start space-x-3 p-2.5 rounded-xl transition <?= $currentPage === 'board.php' ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/70' ?>">
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-100 dark:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="kanban" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold leading-tight">Bord</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 leading-snug">Lanes, post-its & Outlook planning</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 2. Tijdlijn (Gantt) -->
|
||||
<a href="/timeline.php" class="flex items-start space-x-3 p-2.5 rounded-xl transition <?= $currentPage === 'timeline.php' ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/70' ?>">
|
||||
<div class="w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-500/20 text-violet-600 dark:text-violet-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold leading-tight">Tijdlijn</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 leading-snug">Gantt-planning & projectkalender</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 3. Brainstorm -->
|
||||
<a href="/brainstorm.php" class="flex items-start space-x-3 p-2.5 rounded-xl transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/70' ?>">
|
||||
<div class="w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4"></i>
|
||||
<span>Brainstorm</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold leading-tight">Brainstorm</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 leading-snug">Ideeënsessies & canvas</div>
|
||||
</div>
|
||||
</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' ?>">
|
||||
|
||||
<!-- 3. Roadmap -->
|
||||
<a href="/roadmap.php" class="flex items-start space-x-3 p-2.5 rounded-xl transition <?= $currentPage === 'roadmap.php' ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/70' ?>">
|
||||
<div class="w-8 h-8 rounded-lg bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="map" class="w-4 h-4"></i>
|
||||
<span>Roadmap</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold leading-tight">Roadmap</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 leading-snug">Strategische planning & kwartalen</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/profile.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'profile.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="user" class="w-4 h-4"></i>
|
||||
<span>Mijn Profiel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/jira.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'jira.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="layers" class="w-4 h-4 text-sky-500"></i>
|
||||
<span>Jira</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' ?>">
|
||||
<a href="/users.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'users.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="users" class="w-4 h-4"></i>
|
||||
<span>Team</span>
|
||||
</a>
|
||||
<a href="/admin.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'admin.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="shield-check" class="w-4 h-4 text-emerald-400"></i>
|
||||
<a href="/admin.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'admin.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="shield-check" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Beheer</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Right side: User, Notifications & 2FA Status -->
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<button type="button" onclick="openNotificationModal()" class="relative p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition" title="Meldingen & Agenda Herinneringen">
|
||||
<i data-lucide="bell" class="w-4 h-4"></i>
|
||||
<span id="focizNotificationDot" class="hidden absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
<!-- Center: Unified Dashboard Toolbar Pill (Gecentreerd in de header) -->
|
||||
<?php if ($currentPage === 'planner.php' && !empty($formattedSelectedDate)): ?>
|
||||
<div class="flex items-center justify-center flex-1 mx-2 lg:mx-4 shrink-0">
|
||||
<div class="flex items-center bg-slate-100/80 dark:bg-slate-800/60 rounded-xl p-1 border border-slate-200/80 dark:border-slate-700/60 shadow-2xs whitespace-nowrap">
|
||||
<!-- Date & Status -->
|
||||
<div class="flex items-center space-x-2 px-2.5 py-1 text-xs text-slate-700 dark:text-slate-200 select-none">
|
||||
<div class="w-6 h-6 rounded-lg bg-indigo-50 dark:bg-indigo-500/15 text-indigo-600 dark:text-indigo-400 flex items-center justify-center shrink-0">
|
||||
<i data-lucide="calendar" class="w-3.5 h-3.5"></i>
|
||||
</div>
|
||||
<span id="headerCurrentDateDisplay" class="font-semibold tracking-tight whitespace-nowrap leading-none"><?= htmlspecialchars($formattedSelectedDate) ?></span>
|
||||
|
||||
<!-- Badges -->
|
||||
<span id="headerDateBadgesContainer" class="inline-flex items-center gap-1.5 ml-1">
|
||||
<?php if (!empty($isSelectedDateWorkday)): ?>
|
||||
<span class="hidden lg:inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 leading-none" title="Reguliere werkdag">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
<span>Werkdag</span>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 leading-none" title="Weekend / Vrije dag">
|
||||
<i data-lucide="coffee" class="w-2.5 h-2.5 text-amber-500"></i>
|
||||
<span>Vrij</span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($isSelectedDateBlocked)): ?>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30 cursor-pointer hover:bg-amber-500/25 transition leading-none" onclick="openCalendarBlockModal(<?= htmlspecialchars(json_encode($selectedDayBlock), ENT_QUOTES) ?>)" title="<?= htmlspecialchars($selectedDayBlock['title'] ?? '') ?> (klik om te bewerken)">
|
||||
<i data-lucide="palmtree" class="w-2.5 h-2.5 text-amber-600 dark:text-amber-400"></i>
|
||||
<span><?= htmlspecialchars($selectedDayBlock['title'] ?? '') ?></span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700 mx-0.5 shrink-0 self-center"></div>
|
||||
|
||||
<!-- Instellingen Tandwiel Dropdown -->
|
||||
<div class="relative flex items-center justify-center">
|
||||
<button id="plannerSettingsDropdownBtn" onclick="togglePlannerSettingsMenu(event)" class="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-white dark:hover:bg-slate-700/60 transition cursor-pointer" title="Dashboard Opties & Instellingen">
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div id="plannerSettingsDropdownMenu" class="hidden absolute right-0 top-full mt-2 w-56 rounded-2xl bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 shadow-xl py-1.5 z-50 text-xs text-slate-700 dark:text-slate-300">
|
||||
<button onclick="closePlannerSettingsMenu(); openWidgetManagerModal();" class="w-full text-left px-3.5 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/70 flex items-center space-x-2.5 transition cursor-pointer">
|
||||
<i data-lucide="layout-grid" class="w-4 h-4 text-indigo-500"></i>
|
||||
<span class="font-medium">Widgets & Indeling</span>
|
||||
</button>
|
||||
<button onclick="closePlannerSettingsMenu(); openCalendarBlockModal();" class="w-full text-left px-3.5 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/70 flex items-center space-x-2.5 transition cursor-pointer">
|
||||
<i data-lucide="palmtree" class="w-4 h-4 text-amber-500"></i>
|
||||
<span class="font-medium">Vakantie / Dagen Blokkeren</span>
|
||||
</button>
|
||||
<?php if (!empty($isOutlookConnected)): ?>
|
||||
<button onclick="closePlannerSettingsMenu(); triggerManualSync();" class="w-full text-left px-3.5 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/70 flex items-center space-x-2.5 transition cursor-pointer border-t border-slate-100 dark:border-slate-800/80">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4 text-sky-500"></i>
|
||||
<span class="font-medium">Outlook Synchroniseren</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<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>
|
||||
<!-- Right side: Clean User Profile & Logout -->
|
||||
<div class="flex items-center space-x-2 sm:space-x-3 shrink-0">
|
||||
|
||||
<a href="/profile.php" class="text-right hidden sm:block group hover:opacity-90 transition" title="Mijn Profiel & Instellingen">
|
||||
<div class="text-xs font-semibold text-slate-200 group-hover:text-emerald-400 transition flex items-center justify-end space-x-1">
|
||||
<span><?= htmlspecialchars($currentUser['name']) ?></span>
|
||||
<i data-lucide="chevron-right" class="w-3 h-3 text-slate-500 group-hover:text-emerald-400 transition"></i>
|
||||
<!-- Profiel link rechtsboven -->
|
||||
<a href="/profile.php" class="flex items-center space-x-2.5 group hover:opacity-90 transition px-2 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60 shrink-0" title="Mijn Profiel & Instellingen">
|
||||
<div class="w-7 h-7 rounded-lg bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 shadow-xs overflow-hidden">
|
||||
<?php if (!empty($currentUser['avatar_url'])): ?>
|
||||
<img src="<?= htmlspecialchars($currentUser['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-[6px] object-cover bg-white dark:bg-slate-950" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
|
||||
<div class="hidden w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500">
|
||||
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?> • <?= htmlspecialchars($currentUser['email']) ?>
|
||||
<?php else: ?>
|
||||
<div class="w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="text-left hidden sm:block">
|
||||
<div class="text-xs font-semibold text-slate-800 dark:text-slate-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition leading-tight">
|
||||
<?= htmlspecialchars($currentUser['name']) ?>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 dark:text-slate-400 leading-tight">
|
||||
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
|
||||
</div>
|
||||
</div>
|
||||
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400 dark:text-slate-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition hidden sm:block"></i>
|
||||
</a>
|
||||
|
||||
<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">
|
||||
<!-- Uitloggen -->
|
||||
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-xl text-slate-400 hover:text-red-600 hover:bg-red-50 dark:text-slate-400 dark:hover:text-red-400 dark:hover:bg-red-500/10 transition shrink-0">
|
||||
<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>
|
||||
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-200/80 dark:border-slate-800/60 text-xs">
|
||||
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="layout-dashboard" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Fociz</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' ?>">
|
||||
<a href="/mail.php" class="relative flex flex-col items-center py-1 <?= $currentPage === 'mail.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<div class="relative">
|
||||
<i data-lucide="mail" class="w-4 h-4 mb-0.5"></i>
|
||||
<span id="navMailBadgeMobile" class="hidden absolute -top-1 -right-2 min-w-[14px] h-[14px] px-0.5 rounded-full text-[8px] font-black bg-sky-500 text-white flex items-center justify-center leading-none ring-2 ring-white dark:ring-[#0c0f17]"></span>
|
||||
</div>
|
||||
<span>Mail</span>
|
||||
</a>
|
||||
<a href="/board.php" class="flex flex-col items-center py-1 <?= $currentPage === 'board.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="kanban" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Bord</span>
|
||||
</a>
|
||||
<a href="/brainstorm.php" class="flex flex-col items-center py-1 <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark: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' ?>">
|
||||
<a href="/roadmap.php" class="flex flex-col items-center py-1 <?= $currentPage === 'roadmap.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="map" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Roadmap</span>
|
||||
</a>
|
||||
<a href="/profile.php" class="flex flex-col items-center py-1 <?= $currentPage === 'profile.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
|
||||
<i data-lucide="user" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Profiel</span>
|
||||
<a href="/jira.php" class="flex flex-col items-center py-1 <?= $currentPage === 'jira.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="layers" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Jira</span>
|
||||
</a>
|
||||
<button type="button" onclick="openNotificationModal()" class="flex flex-col items-center py-1 text-slate-400 hover:text-white">
|
||||
<i data-lucide="bell" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Meldingen</span>
|
||||
</button>
|
||||
<?php if ($currentUser['role'] === 'ADMIN'): ?>
|
||||
<a href="/admin.php" class="flex flex-col items-center py-1 <?= $currentPage === 'admin.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
|
||||
<a href="/admin.php" class="flex flex-col items-center py-1 <?= $currentPage === 'admin.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="shield" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Beheer</span>
|
||||
</a>
|
||||
@@ -159,21 +392,21 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Fociz Agenda Notificaties Modal -->
|
||||
<div id="focizNotificationModal" class="hidden fixed inset-0 z-50 bg-black/80 backdrop-blur-sm items-center justify-center p-4">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<!-- Fociz Agenda Notificaties Modal (Dual-Mode) -->
|
||||
<div id="focizNotificationModal" class="hidden fixed inset-0 z-50 bg-black/60 dark:bg-black/80 backdrop-blur-sm items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl w-full max-w-md overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<!-- Modal Header -->
|
||||
<div class="p-5 border-b border-slate-800/80 flex items-center justify-between">
|
||||
<div class="p-5 border-b border-slate-100 dark:border-slate-800/80 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-400 flex items-center justify-center font-bold">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-600 dark:text-blue-400 flex items-center justify-center font-bold">
|
||||
<i data-lucide="bell-ring" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-white">Agenda Push Meldingen</h3>
|
||||
<p class="text-[11px] text-slate-400">Persoonlijke waarschuwingen bij actuele afspraken</p>
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Agenda Push Meldingen</h3>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400">Persoonlijke waarschuwingen bij actuele afspraken</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="closeNotificationModal()" class="text-slate-500 hover:text-white p-1 rounded-lg transition">
|
||||
<button type="button" onclick="closeNotificationModal()" class="text-slate-400 hover:text-slate-700 dark:hover:text-white p-1 rounded-lg transition">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -181,49 +414,49 @@ require_once __DIR__ . '/env_badge.php';
|
||||
<!-- Inhoud Formulier -->
|
||||
<form onsubmit="saveNotificationPreferences(event)" class="p-5 space-y-4 text-xs">
|
||||
<!-- Browser Permissie Banner -->
|
||||
<div id="notifPermGranted" class="hidden p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 flex items-center justify-between">
|
||||
<div id="notifPermGranted" class="hidden p-3 rounded-xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 text-indigo-800 dark:text-indigo-300 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-400 flex-shrink-0"></i>
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-indigo-600 dark:text-indigo-400 flex-shrink-0"></i>
|
||||
<span class="text-[11px]">Browser pushberichten zijn actief</span>
|
||||
</div>
|
||||
<button type="button" onclick="focizTestNotification()" class="px-2 py-1 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 font-semibold text-[10px] transition">
|
||||
<button type="button" onclick="focizTestNotification()" class="px-2 py-1 rounded-lg bg-indigo-100 dark:bg-indigo-500/20 hover:bg-indigo-200 dark:hover:bg-indigo-500/30 text-indigo-800 dark:text-indigo-300 font-semibold text-[10px] transition">
|
||||
Test Melding
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="notifPermRequest" class="p-3 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-200 flex items-center justify-between">
|
||||
<div id="notifPermRequest" class="p-3 rounded-xl bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/30 text-sky-900 dark:text-sky-200 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="bell" class="w-4 h-4 text-blue-400 flex-shrink-0"></i>
|
||||
<i data-lucide="bell" class="w-4 h-4 text-sky-600 dark:text-sky-400 flex-shrink-0"></i>
|
||||
<span class="text-[11px]">Toestemming browser vereist</span>
|
||||
</div>
|
||||
<button type="button" onclick="focizRequestPermission()" class="px-2.5 py-1 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-bold text-[10px] transition shadow">
|
||||
<button type="button" onclick="focizRequestPermission()" class="px-2.5 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-[10px] transition shadow-xs">
|
||||
Inschakelen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="notifPermDenied" class="hidden p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-red-300 text-[11px] leading-relaxed">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 text-red-400 inline mr-1 -mt-0.5"></i>
|
||||
Browser meldingen zijn geblokkeerd. Klik op het slotje links in de browser adresbalk om meldingen toe te staan.
|
||||
<div id="notifPermDenied" class="hidden p-3 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-800 dark:text-rose-300 text-[11px] leading-relaxed">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 text-rose-600 dark:text-rose-400 inline mr-1 -mt-0.5"></i>
|
||||
Browser meldingen zijn geblokkeerd. Klik op het slotje links in de adresbalk om meldingen toe te staan.
|
||||
</div>
|
||||
|
||||
<!-- Toggle Meldingen Inschakelen -->
|
||||
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-950/60 border border-slate-800">
|
||||
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
|
||||
<div>
|
||||
<span class="font-semibold text-white block">Meldingen Actief</span>
|
||||
<span class="text-[10px] text-slate-400">Automatisch waarschuwen bij naderende afspraken</span>
|
||||
<span class="font-semibold text-slate-900 dark:text-white block">Meldingen Actief</span>
|
||||
<span class="text-[10px] text-slate-500 dark:text-slate-400">Automatisch waarschuwen bij naderende afspraken</span>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="notifEnabledInput" class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Vooraankondigingstijd -->
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Hoeveel tijd van tevoren waarschuwen?
|
||||
</label>
|
||||
<select id="notifLeadTimeSelect" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500">
|
||||
<select id="notifLeadTimeSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
|
||||
<option value="0">0 minuten (Precies op aanvangstijd)</option>
|
||||
<option value="2">2 minuten van tevoren</option>
|
||||
<option value="5" selected>5 minuten van tevoren (Aanbevolen)</option>
|
||||
@@ -236,10 +469,10 @@ require_once __DIR__ . '/env_badge.php';
|
||||
<!-- Aantal Herhalingen & Interval Grid -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Aantal herhalingen
|
||||
</label>
|
||||
<select id="notifRepeatCountSelect" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500">
|
||||
<select id="notifRepeatCountSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
|
||||
<option value="1">1x (Alleen 1e melding)</option>
|
||||
<option value="2" selected>2x (Aanbevolen)</option>
|
||||
<option value="3">3x</option>
|
||||
@@ -248,10 +481,10 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Herhalingsinterval
|
||||
</label>
|
||||
<select id="notifIntervalSelect" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500">
|
||||
<select id="notifIntervalSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
|
||||
<option value="1">Elke 1 minuut</option>
|
||||
<option value="2" selected>Elke 2 minuten</option>
|
||||
<option value="3">Elke 3 minuten</option>
|
||||
@@ -261,28 +494,28 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</div>
|
||||
|
||||
<!-- Geluidssignaal Toggle -->
|
||||
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-950/60 border border-slate-800">
|
||||
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="volume-2" class="w-4 h-4 text-blue-400"></i>
|
||||
<i data-lucide="volume-2" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<div>
|
||||
<span class="font-semibold text-white block">Geluidssignaal</span>
|
||||
<span class="text-[10px] text-slate-400">Zachte bel/chime afspelen bij melding</span>
|
||||
<span class="font-semibold text-slate-900 dark:text-white block">Geluidssignaal</span>
|
||||
<span class="text-[10px] text-slate-500 dark:text-slate-400">Zachte chime afspelen bij melding</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="notifSoundInput" checked class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="notifSaveStatus" class="hidden text-emerald-400 text-center font-semibold text-[11px] py-1"></div>
|
||||
<div id="notifSaveStatus" class="hidden text-indigo-600 dark:text-indigo-400 text-center font-semibold text-[11px] py-1"></div>
|
||||
|
||||
<!-- Knoppen -->
|
||||
<div class="pt-2 flex items-center space-x-2">
|
||||
<button type="button" onclick="closeNotificationModal()" class="flex-1 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold transition">
|
||||
<button type="button" onclick="closeNotificationModal()" class="flex-1 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold transition">
|
||||
Annuleren
|
||||
</button>
|
||||
<button type="submit" id="saveNotifBtn" class="flex-1 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-bold transition shadow-md shadow-blue-500/20">
|
||||
<button type="submit" id="saveNotifBtn" class="flex-1 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold transition shadow-sm shadow-indigo-600/20">
|
||||
Voorkeuren Opslaan
|
||||
</button>
|
||||
</div>
|
||||
@@ -291,5 +524,47 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
|
||||
<script>
|
||||
/**
|
||||
* Universele Thema-controller voor Fociz
|
||||
*/
|
||||
function focizSetTheme(theme, saveServer = true) {
|
||||
localStorage.setItem('fociz_theme', theme);
|
||||
let effective = theme;
|
||||
if (theme === 'system') {
|
||||
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
if (effective === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Update Lucide pictogrammen in header
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Update profiel preview selectoren als aanwezig op pagina
|
||||
if (typeof updateProfileThemeUI === 'function') {
|
||||
updateProfileThemeUI(theme);
|
||||
}
|
||||
|
||||
if (saveServer) {
|
||||
fetch('/api/theme.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme: theme })
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function focizQuickToggleTheme() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const nextTheme = isDark ? 'light' : 'dark';
|
||||
focizSetTheme(nextTheme, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php $isBoardPage = in_array($currentPage, ['board.php', 'timeline.php']); ?>
|
||||
<main id="focizMainContainer" class="flex-1 <?= $isBoardPage ? 'w-full max-w-none px-3 sm:px-6 py-4' : 'max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8' ?> transition-all duration-300">
|
||||
|
||||
@@ -70,7 +70,8 @@ if (!function_exists('fociz_http_request')) {
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(4, max(1, $timeout - 1)));
|
||||
curl_setopt($ch, CURLOPT_DNS_CACHE_TIMEOUT, 600);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
http_response_code(403);
|
||||
exit('Toegang geweigerd.');
|
||||
@@ -0,0 +1,648 @@
|
||||
<?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;
|
||||
};
|
||||
|
||||
// Saniteer e-mailadressen en headers tegen CRLF en SMTP command injection
|
||||
$toEmail = str_replace(["\r", "\n", "\0"], '', trim($toEmail));
|
||||
$toName = str_replace(["\r", "\n", "\0"], '', trim($toName));
|
||||
$subject = str_replace(["\r", "\n", "\0"], '', trim($subject));
|
||||
|
||||
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
$err = "Ongeldig e-mailadres voor verzending: {$toEmail}";
|
||||
$log("FOUT: {$err}");
|
||||
return ['success' => false, 'error' => $err, 'logs' => $logs];
|
||||
}
|
||||
|
||||
// 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 = str_replace(["\r", "\n", "\0"], '', trim($options['from_email'] ?? (defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl')));
|
||||
$fromName = str_replace(["\r", "\n", "\0"], '', trim($options['from_name'] ?? (defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Fociz')));
|
||||
$replyTo = str_replace(["\r", "\n", "\0"], '', trim($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 conform RFC 5321 §4.1.4
|
||||
// Voorkom impersonation van het lokale domein (bijv. 'fociz.nl' tegen 'mail.fociz.nl')
|
||||
// Antagonist/Exim weigert 'EHLO fociz.nl' van externe hosts met '550 Bad HELO - Host impersonating domain name'
|
||||
$resolveHeloHost = function() use ($host): string {
|
||||
// 1. Mailserver hostnaam indien beschikbaar en niet een lokaal IP
|
||||
if (!empty($host) && str_contains($host, '.') && !str_starts_with($host, '192.168.') && !str_starts_with($host, '127.')) {
|
||||
return $host;
|
||||
}
|
||||
// 2. SERVER_NAME mits het niet het bare hoofddomein is
|
||||
if (!empty($_SERVER['SERVER_NAME'])) {
|
||||
$sn = preg_replace('/:[0-9]+$/', '', trim($_SERVER['SERVER_NAME']));
|
||||
if (!in_array(strtolower($sn), ['fociz.nl', 'localhost', '127.0.0.1'])) {
|
||||
return $sn;
|
||||
}
|
||||
}
|
||||
// 3. Systeemhostnaam
|
||||
if (function_exists('gethostname')) {
|
||||
$gh = gethostname();
|
||||
if (!empty($gh) && strtolower($gh) !== 'fociz.nl') {
|
||||
return $gh;
|
||||
}
|
||||
}
|
||||
return 'mail.fociz.nl';
|
||||
};
|
||||
|
||||
$performHeloHandshake = function(string $preferredHost) use ($sendCommand, $log): string {
|
||||
$candidates = array_unique([$preferredHost, 'mail.fociz.nl', 'localhost', '127.0.0.1']);
|
||||
$lastResp = null;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$resp = $sendCommand("EHLO {$candidate}");
|
||||
if ($resp['code'] === 250) {
|
||||
return $candidate;
|
||||
}
|
||||
$lastResp = $resp;
|
||||
$log("Waarschuwing: EHLO {$candidate} niet geaccepteerd (code {$resp['code']}). Probeert alternatief...");
|
||||
}
|
||||
|
||||
// Fallback naar HELO indien EHLO op alle kandidaten niet werkt
|
||||
foreach ($candidates as $candidate) {
|
||||
$resp = $sendCommand("HELO {$candidate}");
|
||||
if ($resp['code'] === 250) {
|
||||
return $candidate;
|
||||
}
|
||||
$lastResp = $resp;
|
||||
}
|
||||
|
||||
throw new Exception("EHLO/HELO geweigerd door server voor alle kandidaten. Laatste reactie: " . ($lastResp ? "({$lastResp['code']}) {$lastResp['text']}" : 'onbekend'));
|
||||
};
|
||||
|
||||
$preferredHost = $resolveHeloHost();
|
||||
$clientHost = $performHeloHandshake($preferredHost);
|
||||
|
||||
// 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)
|
||||
$clientHost = $performHeloHandshake($clientHost);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
/**
|
||||
* Centrale Systeeminstellingen Helper voor Fociz
|
||||
* Beheert organisatie-brede werkdagstandaarden (v1.4) en AI configuratie (v1.5).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
/**
|
||||
* Zorgt ervoor dat de system_settings tabel bestaat in MariaDB
|
||||
*/
|
||||
function ensure_system_settings_table(?PDO $db = null): void {
|
||||
static $ensured = false;
|
||||
if ($ensured) return;
|
||||
|
||||
try {
|
||||
$pdo = $db ?: get_db_connection();
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS `system_settings` (
|
||||
`setting_key` VARCHAR(64) PRIMARY KEY,
|
||||
`setting_value` TEXT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
");
|
||||
$ensured = true;
|
||||
} catch (Throwable $e) {
|
||||
error_log("ensure_system_settings_table error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt alle systeeminstellingen in 1 geoptimaliseerde query op en bewaart deze in het static runtime geheugen
|
||||
*/
|
||||
function get_all_system_settings(bool $forceRefresh = false): array {
|
||||
static $settingsCache = null;
|
||||
if ($settingsCache !== null && !$forceRefresh) {
|
||||
return $settingsCache;
|
||||
}
|
||||
|
||||
$settingsCache = [];
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_system_settings_table($db);
|
||||
|
||||
$stmt = $db->query("SELECT setting_key, setting_value FROM system_settings");
|
||||
while ($row = $stmt->fetch()) {
|
||||
$val = $row['setting_value'];
|
||||
if ($val === null) {
|
||||
$settingsCache[$row['setting_key']] = null;
|
||||
continue;
|
||||
}
|
||||
$trimmed = trim((string)$val);
|
||||
if (str_starts_with($trimmed, '{') || str_starts_with($trimmed, '[')) {
|
||||
$decoded = json_decode($val, true);
|
||||
$settingsCache[$row['setting_key']] = (json_last_error() === JSON_ERROR_NONE) ? $decoded : $val;
|
||||
} else {
|
||||
$settingsCache[$row['setting_key']] = $val;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("get_all_system_settings error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $settingsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt een enkele instelling op uit het geheugen (of fallback default)
|
||||
*/
|
||||
function get_system_setting(string $key, $default = null) {
|
||||
$all = get_all_system_settings();
|
||||
if (array_key_exists($key, $all) && $all[$key] !== null && $all[$key] !== '') {
|
||||
return $all[$key];
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slaat een instelling op in MariaDB en ververst direct het cachegeheugen
|
||||
*/
|
||||
function set_system_setting(string $key, $value): bool {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_system_settings_table($db);
|
||||
|
||||
$valToStore = is_array($value) || is_object($value) ? json_encode($value) : (string)$value;
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO system_settings (setting_key, setting_value)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()
|
||||
");
|
||||
$ok = $stmt->execute([$key, $valToStore]);
|
||||
if ($ok) {
|
||||
get_all_system_settings(true);
|
||||
}
|
||||
return $ok;
|
||||
} catch (Throwable $e) {
|
||||
error_log("set_system_setting error for {$key}: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt alle organisatie werkdag standaarden op (Release v1.4)
|
||||
*/
|
||||
function get_workday_settings(): array {
|
||||
$rawDays = get_system_setting('workdays', '1,2,3,4,5');
|
||||
$workdays = array_values(array_filter(array_map('intval', explode(',', $rawDays)), fn($d) => $d >= 1 && $d <= 7));
|
||||
if (empty($workdays)) {
|
||||
$workdays = [1, 2, 3, 4, 5];
|
||||
}
|
||||
return [
|
||||
'workday_start' => get_system_setting('workday_start', '08:30'),
|
||||
'workday_end' => get_system_setting('workday_end', '17:00'),
|
||||
'lunch_start' => get_system_setting('lunch_start', '12:30'),
|
||||
'lunch_end' => get_system_setting('lunch_end', '13:00'),
|
||||
'pomodoro_focus' => (int)get_system_setting('pomodoro_focus', 25),
|
||||
'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50),
|
||||
'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5),
|
||||
'default_task_duration' => (int)get_system_setting('default_task_duration', 30),
|
||||
'workdays' => $workdays,
|
||||
'workdays_str' => implode(',', $workdays),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt AI configuratie-instellingen op (Release v2.0 - Google Gemini Pro & OpenAI)
|
||||
* Ondersteunt organisatiebrede en optioneel persoonlijke sleutels.
|
||||
*/
|
||||
function get_ai_settings(?int $userId = null): array {
|
||||
if (!function_exists('fociz_decrypt')) {
|
||||
require_once __DIR__ . '/auth.php';
|
||||
}
|
||||
|
||||
$apiKey = '';
|
||||
$isPersonal = false;
|
||||
$personalProvider = null;
|
||||
$personalModel = null;
|
||||
|
||||
// 1. Controleer persoonlijke sleutel van de ingelogde gebruiker
|
||||
if ($userId !== null && $userId > 0) {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("SELECT access_token, settings, is_enabled FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') AND is_enabled = 1 ORDER BY id DESC LIMIT 1");
|
||||
$stmt->execute([$userId]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row) {
|
||||
// Sleutel kan in access_token staan of in settings['api_key']
|
||||
if (!empty($row['access_token'])) {
|
||||
$apiKey = fociz_decrypt((string)$row['access_token']);
|
||||
$isPersonal = true;
|
||||
} elseif (!empty($row['settings'])) {
|
||||
$userSettings = json_decode($row['settings'], true) ?: [];
|
||||
if (!empty($userSettings['api_key'])) {
|
||||
$apiKey = fociz_decrypt((string)$userSettings['api_key']);
|
||||
$isPersonal = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($row['settings'])) {
|
||||
$userSettings = json_decode($row['settings'], true) ?: [];
|
||||
if (!empty($userSettings['provider'])) {
|
||||
$personalProvider = (string)$userSettings['provider'];
|
||||
}
|
||||
if (!empty($userSettings['model'])) {
|
||||
$personalModel = (string)$userSettings['model'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen persoonlijke AI sleutel: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback naar organisatiebrede instellingen
|
||||
if (empty($apiKey)) {
|
||||
$savedKeyRaw = get_system_setting('ai_api_key', '');
|
||||
if (!empty($savedKeyRaw)) {
|
||||
$apiKey = fociz_decrypt((string)$savedKeyRaw);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback naar .env variabelen (GEMINI_API_KEY of OPENAI_API_KEY)
|
||||
if (empty($apiKey)) {
|
||||
$apiKey = (string)(fociz_env('GEMINI_API_KEY', '') ?: fociz_env('OPENAI_API_KEY', ''));
|
||||
}
|
||||
|
||||
$provider = !empty($personalProvider) ? $personalProvider : get_system_setting('ai_provider', 'gemini');
|
||||
if (empty($provider)) {
|
||||
$provider = 'gemini';
|
||||
}
|
||||
|
||||
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-2.0-flash');
|
||||
if (empty($model)) {
|
||||
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-2.0-flash';
|
||||
}
|
||||
|
||||
$apiKey = trim((string)$apiKey);
|
||||
$hasKey = ($apiKey !== '');
|
||||
|
||||
return [
|
||||
'ai_enabled' => (bool)get_system_setting('ai_enabled', $hasKey),
|
||||
'ai_provider' => (string)$provider,
|
||||
'ai_api_key' => $apiKey,
|
||||
'ai_model' => (string)$model,
|
||||
'has_key' => $hasKey,
|
||||
'is_personal_key'=> $isPersonal,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt geconfigureerde sessie idle timeout in seconden op conform NIST SP 800-63B / ISO 27001
|
||||
* Toegestane waarden: 900 (15m), 1800 (30m - default), 3600 (60m), 7200 (120m)
|
||||
*/
|
||||
function get_session_idle_timeout(): int {
|
||||
$timeout = (int)get_system_setting('session_idle_timeout', 1800);
|
||||
$validTimeouts = [900, 1800, 3600, 7200];
|
||||
return in_array($timeout, $validTimeouts, true) ? $timeout : 1800;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de beheerbare dagblokkade-redenen op (bijv. Vakantie, Verlof, Ziek, Focusdag, ADV).
|
||||
* Standaard voorzien van een duidelijke titel, emoji-icoon en actief-status.
|
||||
*/
|
||||
function get_calendar_block_reasons(): array {
|
||||
$defaultReasons = [
|
||||
['code' => 'VACATION', 'title' => 'Vakantie', 'icon' => '🏖️', 'is_active' => 1],
|
||||
['code' => 'LEAVE', 'title' => 'Verlof / Vrij', 'icon' => '✈️', 'is_active' => 1],
|
||||
['code' => 'SICK', 'title' => 'Ziek / Afwezig', 'icon' => '🤒', 'is_active' => 1],
|
||||
['code' => 'FOCUS', 'title' => 'Focusdag', 'icon' => '🎯', 'is_active' => 1],
|
||||
['code' => 'ADV', 'title' => 'ADV Dag', 'icon' => '📋', 'is_active' => 1],
|
||||
];
|
||||
|
||||
$saved = get_system_setting('calendar_block_reasons', null);
|
||||
if (is_array($saved) && !empty($saved)) {
|
||||
return $saved;
|
||||
}
|
||||
return $defaultReasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de beheerbare brainstorm categorieën op.
|
||||
* Standaard voorzien van een duidelijke categorienaam, beschrijving en actief-status.
|
||||
*/
|
||||
function get_brainstorm_categories(): array {
|
||||
$defaultCategories = [
|
||||
['name' => 'Product & Strategie', 'description' => 'Strategische richting, nieuwe producten en visie', 'is_active' => 1],
|
||||
['name' => 'Procesverbetering', 'description' => 'Optimalisatie van werkprocessen en efficiëntie', 'is_active' => 1],
|
||||
['name' => 'Klant & Service', 'description' => 'Klanttevredenheid, support en klantervaring', 'is_active' => 1],
|
||||
['name' => 'Technologie & IT', 'description' => 'Architectuur, tooling, infrastructuur en tech stack','is_active' => 1],
|
||||
['name' => 'Marketing & Groei', 'description' => 'Leadgeneratie, branding en marktpositie', 'is_active' => 1],
|
||||
];
|
||||
|
||||
$saved = get_system_setting('brainstorm_categories', null);
|
||||
if (is_array($saved) && !empty($saved)) {
|
||||
return $saved;
|
||||
}
|
||||
return $defaultCategories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registreert AI verbruiksstatistieken (aanroepen, tokens, provider/model)
|
||||
*/
|
||||
function record_ai_usage(string $provider, string $model, int $promptTokens = 0, int $completionTokens = 0, ?int $userId = null): void {
|
||||
try {
|
||||
$stats = get_system_setting('ai_usage_stats', [
|
||||
'total_requests' => 0,
|
||||
'total_prompt_tokens' => 0,
|
||||
'total_completion_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
'last_used_at' => null,
|
||||
'by_provider' => [],
|
||||
'by_model' => [],
|
||||
'by_date' => [],
|
||||
]);
|
||||
|
||||
$stats['total_requests'] = (int)($stats['total_requests'] ?? 0) + 1;
|
||||
$stats['total_prompt_tokens'] = (int)($stats['total_prompt_tokens'] ?? 0) + $promptTokens;
|
||||
$stats['total_completion_tokens'] = (int)($stats['total_completion_tokens'] ?? 0) + $completionTokens;
|
||||
$stats['total_tokens'] = (int)($stats['total_tokens'] ?? 0) + ($promptTokens + $completionTokens);
|
||||
$stats['last_used_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
// Per provider
|
||||
if (!isset($stats['by_provider'][$provider])) {
|
||||
$stats['by_provider'][$provider] = 0;
|
||||
}
|
||||
$stats['by_provider'][$provider]++;
|
||||
|
||||
// Per model
|
||||
if (!isset($stats['by_model'][$model])) {
|
||||
$stats['by_model'][$model] = 0;
|
||||
}
|
||||
$stats['by_model'][$model]++;
|
||||
|
||||
// Per datum
|
||||
$today = date('Y-m-d');
|
||||
if (!isset($stats['by_date'][$today])) {
|
||||
$stats['by_date'][$today] = ['requests' => 0, 'tokens' => 0];
|
||||
}
|
||||
$stats['by_date'][$today]['requests']++;
|
||||
$stats['by_date'][$today]['tokens'] += ($promptTokens + $completionTokens);
|
||||
|
||||
// Maximaal 30 dagen historiek
|
||||
if (count($stats['by_date']) > 30) {
|
||||
ksort($stats['by_date']);
|
||||
$stats['by_date'] = array_slice($stats['by_date'], -30, 30, true);
|
||||
}
|
||||
|
||||
set_system_setting('ai_usage_stats', $stats);
|
||||
} catch (Throwable $e) {
|
||||
error_log("record_ai_usage fout: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de AI verbruiksstatistieken op
|
||||
*/
|
||||
function get_ai_usage_stats(): array {
|
||||
$stats = get_system_setting('ai_usage_stats', null);
|
||||
if (!is_array($stats)) {
|
||||
return [
|
||||
'total_requests' => 0,
|
||||
'total_prompt_tokens' => 0,
|
||||
'total_completion_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
'last_used_at' => null,
|
||||
'by_provider' => [],
|
||||
'by_model' => [],
|
||||
'by_date' => [],
|
||||
];
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -107,14 +107,12 @@ class TOTP {
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een QR code afbeelding URL (via QuickChart HTTPS)
|
||||
* Genereert een veilige client-side QR code (geen externe API calls meer ter bescherming van TOTP secrets)
|
||||
*/
|
||||
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
|
||||
);
|
||||
// Externe API (quickchart.io) verwijderd om te voorkomen dat 2FA secrets uitlekken naar derden.
|
||||
// QR rendering gebeurt nu 100% lokaal via /assets/js/qrcode.min.js in de browser.
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,3 +128,17 @@ class TOTP {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Globale helper functies voor 2FA TOTP verificatie
|
||||
*/
|
||||
if (!function_exists('verify_totp')) {
|
||||
function verify_totp(string $secret, string $userInputCode, int $window = 1): bool {
|
||||
return TOTP::verifyCode($secret, $userInputCode, $window);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('get_totp_code')) {
|
||||
function get_totp_code(string $secret, ?int $timestamp = null): string {
|
||||
return TOTP::getCode($secret, $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,45 +27,73 @@ if ($selectedStatus !== 'ALL') {
|
||||
$params[] = $selectedStatus;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY r.quarter ASC, r.priority DESC, r.due_date ASC";
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$roadmapItems = $stmt->fetchAll();
|
||||
$roadmapItems = [];
|
||||
try {
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$roadmapItems = $stmt->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen roadmap items: " . $e->getMessage());
|
||||
ensure_tables_exist($db);
|
||||
}
|
||||
|
||||
$quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
$currentUserId = (int)($currentUser['id'] ?? 0);
|
||||
|
||||
// Haal alle actieve teamleden op voor toewijzing & agendablokkades
|
||||
$teamMembersStmt = $db->query("SELECT id, name, email, role, avatar_url FROM users ORDER BY name ASC");
|
||||
$teamMembers = $teamMembersStmt->fetchAll();
|
||||
|
||||
$outlookUsers = [];
|
||||
$intStmt = $db->query("SELECT user_id FROM integrations WHERE provider = 'OUTLOOK' AND is_enabled = 1 AND access_token IS NOT NULL");
|
||||
foreach ($intStmt->fetchAll() as $row) {
|
||||
$outlookUsers[(int)$row['user_id']] = true;
|
||||
}
|
||||
foreach ($teamMembers as &$tm) {
|
||||
$tm['has_outlook'] = !empty($outlookUsers[(int)$tm['id']]);
|
||||
}
|
||||
unset($tm);
|
||||
?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-6 max-w-7xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
|
||||
<div class="fociz-card p-6 sm:p-7 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-1">
|
||||
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<i data-lucide="map" class="w-4 h-4"></i>
|
||||
<span>Planning & Strategie</span>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
|
||||
Roadmap & Tijdlijn
|
||||
</h1>
|
||||
<p class="text-slate-400 text-xs sm:text-sm mt-1">
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
||||
Centraal overzicht van alle mijlpalen, features en doelen, direct voortgekomen uit brainstorms en gesynchroniseerd met Jira.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button onclick="document.getElementById('newItemModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-emerald-500 hover:bg-emerald-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-500/20 flex-shrink-0">
|
||||
<button onclick="document.getElementById('newItemModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs flex-shrink-0 cursor-pointer">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
<span>Nieuw Item</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters: Quarters & Status -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 bg-slate-900/40 border border-slate-800/80 rounded-2xl p-4">
|
||||
<!-- Quarter Tabs -->
|
||||
<div class="fociz-card p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<!-- Quarter Tabs (tevens drop targets) -->
|
||||
<div class="flex items-center space-x-1 text-xs">
|
||||
<a href="/roadmap.php?quarter=ALL&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-xl transition font-medium <?= $selectedQuarter === 'ALL' ? 'bg-emerald-500 text-slate-950 font-bold' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800' ?>">
|
||||
<a href="/roadmap.php?quarter=ALL&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-lg transition font-semibold <?= $selectedQuarter === 'ALL' ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/80' ?>">
|
||||
Alle Quarters
|
||||
</a>
|
||||
<?php foreach ($quarters as $q): ?>
|
||||
<a href="/roadmap.php?quarter=<?= $q ?>&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-xl transition font-medium <?= $selectedQuarter === $q ? 'bg-emerald-500 text-slate-950 font-bold' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800' ?>">
|
||||
<a
|
||||
href="/roadmap.php?quarter=<?= $q ?>&status=<?= htmlspecialchars($selectedStatus) ?>"
|
||||
class="roadmap-quarter-tab px-3.5 py-1.5 rounded-lg transition-all font-semibold <?= $selectedQuarter === $q ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/80' ?>"
|
||||
data-quarter="<?= $q ?>"
|
||||
ondragover="handleRoadmapQuarterDragOver(event)"
|
||||
ondragleave="handleRoadmapQuarterDragLeave(event)"
|
||||
ondrop="handleRoadmapQuarterDrop(event, '<?= $q ?>')"
|
||||
title="Sleep een roadmap item hierheen om het kwartaal direct te wijzigen"
|
||||
>
|
||||
<?= $q ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
@@ -73,8 +101,8 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="flex items-center space-x-2 text-xs">
|
||||
<span class="text-slate-500">Status:</span>
|
||||
<select onchange="window.location.href='/roadmap.php?quarter=<?= htmlspecialchars($selectedQuarter) ?>&status='+this.value" class="bg-slate-800 border border-slate-700 rounded-xl px-2.5 py-1 text-white text-xs focus:outline-none">
|
||||
<span class="text-slate-500 dark:text-slate-400 font-semibold">Status:</span>
|
||||
<select onchange="window.location.href='/roadmap.php?quarter=<?= htmlspecialchars($selectedQuarter) ?>&status='+this.value" class="bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-semibold focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="ALL" <?= $selectedStatus === 'ALL' ? 'selected' : '' ?>>Alle Statussen</option>
|
||||
<option value="PLANNED" <?= $selectedStatus === 'PLANNED' ? 'selected' : '' ?>>Gepland</option>
|
||||
<option value="IN_PROGRESS" <?= $selectedStatus === 'IN_PROGRESS' ? 'selected' : '' ?>>In Uitvoering</option>
|
||||
@@ -87,38 +115,61 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
<!-- Roadmap Items Lijst -->
|
||||
<div class="space-y-4">
|
||||
<?php if (empty($roadmapItems)): ?>
|
||||
<div class="bg-slate-900/60 border border-slate-800/80 rounded-2xl p-12 text-center">
|
||||
<i data-lucide="map-pin" class="w-12 h-12 text-emerald-500/40 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold text-white">Geen items gevonden op de roadmap</h3>
|
||||
<p class="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
Ga naar een <a href="/brainstorm.php" class="text-amber-400 underline">Brainstorm sessie</a> en zet ideeën met één klik om naar de roadmap, of voeg handmatig een item toe.
|
||||
<div class="fociz-card p-12 text-center">
|
||||
<i data-lucide="map-pin" class="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white">Geen items gevonden op de roadmap</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
Ga naar een <a href="/brainstorm.php" class="text-indigo-600 dark:text-indigo-400 underline font-medium">Brainstorm sessie</a> en zet ideeën met één klik om naar de roadmap, of voeg handmatig een item toe.
|
||||
</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($roadmapItems as $item): ?>
|
||||
<div class="bg-slate-900/80 border border-slate-800 hover:border-slate-700 rounded-2xl p-5 shadow-lg transition space-y-3" id="roadmap-item-<?= $item['id'] ?>">
|
||||
<?php foreach ($roadmapItems as $item):
|
||||
$statusBorder = match($item['status']) {
|
||||
'COMPLETED' => 'border-l-4 border-l-emerald-500',
|
||||
'IN_PROGRESS' => 'border-l-4 border-l-indigo-500',
|
||||
'REVIEW' => 'border-l-4 border-l-amber-500',
|
||||
default => 'border-l-4 border-l-slate-300 dark:border-l-slate-700',
|
||||
};
|
||||
?>
|
||||
<div
|
||||
id="roadmap-item-<?= $item['id'] ?>"
|
||||
draggable="true"
|
||||
ondragstart="handleRoadmapItemDragStart(event, <?= $item['id'] ?>)"
|
||||
ondragend="handleRoadmapItemDragEnd(event)"
|
||||
oncontextmenu="openRoadmapContextMenu(event, <?= $item['id'] ?>); return false;"
|
||||
data-title="<?= htmlspecialchars($item['title'], ENT_QUOTES) ?>"
|
||||
data-description="<?= htmlspecialchars($item['description'] ?? '', ENT_QUOTES) ?>"
|
||||
data-quarter="<?= htmlspecialchars($item['quarter']) ?>"
|
||||
data-type="<?= htmlspecialchars($item['type']) ?>"
|
||||
data-priority="<?= htmlspecialchars($item['priority']) ?>"
|
||||
data-status="<?= htmlspecialchars($item['status']) ?>"
|
||||
data-due-date="<?= htmlspecialchars($item['due_date'] ?? '') ?>"
|
||||
data-jira-key="<?= htmlspecialchars($item['jira_issue_key'] ?? '') ?>"
|
||||
class="fociz-card-interactive p-5 space-y-3.5 <?= $statusBorder ?> cursor-grab active:cursor-grabbing transition-all duration-150"
|
||||
title="Sleep naar een ander kwartaal (Q1, Q2, Q3, Q4) of klik met rechtermuisknop"
|
||||
>
|
||||
|
||||
<!-- Top header van item -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div class="flex items-center space-x-2.5 flex-wrap">
|
||||
<!-- Quarter badge -->
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 font-mono text-xs font-bold border border-emerald-500/30">
|
||||
<span class="px-2.5 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 font-mono text-xs font-bold border border-indigo-200 dark:border-indigo-500/20">
|
||||
<?= htmlspecialchars($item['quarter']) ?>
|
||||
</span>
|
||||
|
||||
<!-- Type badge -->
|
||||
<span class="px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 text-[10px] font-semibold">
|
||||
<span class="px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-[10px] font-semibold border border-slate-200 dark:border-slate-700">
|
||||
<?= htmlspecialchars($item['type']) ?>
|
||||
</span>
|
||||
|
||||
<!-- Priority badge -->
|
||||
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full <?= $item['priority'] === 'CRITICAL' ? 'bg-red-500/20 text-red-400' : ($item['priority'] === 'HIGH' ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-800 text-slate-400') ?>">
|
||||
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full <?= $item['priority'] === 'CRITICAL' ? 'bg-rose-50 dark:bg-rose-500/10 text-rose-700 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20' : ($item['priority'] === 'HIGH' ? 'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20' : 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700') ?>">
|
||||
<?= htmlspecialchars($item['priority']) ?>
|
||||
</span>
|
||||
|
||||
<!-- Jira Issue Key link -->
|
||||
<?php if (!empty($item['jira_issue_key'])): ?>
|
||||
<a href="/profile.php" class="flex items-center space-x-1 px-2 py-0.5 rounded-full bg-blue-950/60 border border-blue-800/60 text-blue-300 text-[10px] font-mono hover:underline">
|
||||
<a href="/profile.php" class="flex items-center space-x-1 px-2.5 py-0.5 rounded-lg bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 text-sky-700 dark:text-sky-400 text-[10px] font-mono hover:underline">
|
||||
<i data-lucide="layers" class="w-3 h-3"></i>
|
||||
<span><?= htmlspecialchars($item['jira_issue_key']) ?></span>
|
||||
</a>
|
||||
@@ -127,14 +178,14 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
|
||||
<!-- Status Selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<select onchange="updateRoadmapStatus(<?= $item['id'] ?>, this.value)" class="bg-slate-950 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-slate-200 font-semibold focus:outline-none">
|
||||
<select onchange="updateRoadmapStatus(<?= $item['id'] ?>, this.value)" class="bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1 text-xs text-slate-800 dark:text-slate-200 font-bold focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="PLANNED" <?= $item['status'] === 'PLANNED' ? 'selected' : '' ?>>⏳ Gepland</option>
|
||||
<option value="IN_PROGRESS" <?= $item['status'] === 'IN_PROGRESS' ? 'selected' : '' ?>>⚡ In Uitvoering</option>
|
||||
<option value="REVIEW" <?= $item['status'] === 'REVIEW' ? 'selected' : '' ?>>🔍 In Review</option>
|
||||
<option value="COMPLETED" <?= $item['status'] === 'COMPLETED' ? 'selected' : '' ?>>✅ Voltooid</option>
|
||||
</select>
|
||||
|
||||
<button onclick="deleteRoadmapItem(<?= $item['id'] ?>)" title="Verwijderen" class="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition">
|
||||
<button onclick="deleteRoadmapItem(<?= $item['id'] ?>)" title="Verwijderen" class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:text-slate-500 dark:hover:text-rose-400 hover:bg-rose-500/10 transition cursor-pointer">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -142,11 +193,11 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
|
||||
<!-- Titel & Beschrijving -->
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-white leading-snug">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white leading-snug">
|
||||
<?= htmlspecialchars($item['title']) ?>
|
||||
</h3>
|
||||
<?php if (!empty($item['description'])): ?>
|
||||
<p class="text-slate-400 text-xs mt-1 whitespace-pre-wrap">
|
||||
<p class="text-slate-600 dark:text-slate-400 text-xs mt-1 whitespace-pre-wrap leading-relaxed">
|
||||
<?= htmlspecialchars($item['description']) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
@@ -154,33 +205,33 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
|
||||
<!-- Origin Brainstorm snippet -->
|
||||
<?php if (!empty($item['origin_brainstorm_card_id'])): ?>
|
||||
<div class="bg-slate-950/50 rounded-xl p-2.5 text-[11px] text-slate-400 flex items-center space-x-2 border border-slate-800">
|
||||
<i data-lucide="lightbulb" class="w-3.5 h-3.5 text-amber-400 flex-shrink-0"></i>
|
||||
<div class="bg-slate-50/70 dark:bg-[#0c0f17]/60 rounded-xl p-2.5 text-[11px] text-slate-600 dark:text-slate-400 flex items-center space-x-2 border border-slate-200/80 dark:border-slate-800">
|
||||
<i data-lucide="lightbulb" class="w-3.5 h-3.5 text-amber-500 flex-shrink-0"></i>
|
||||
<span class="truncate">Ontstaan uit brainstormsessie</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Voortgangsbalk & Deadline -->
|
||||
<div class="pt-2 border-t border-slate-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||
<div class="pt-2.5 border-t border-slate-100 dark:border-slate-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||
<!-- Progress Slider -->
|
||||
<div class="flex items-center space-x-3 flex-1 max-w-md">
|
||||
<span class="text-slate-400 text-[11px] font-medium w-16">Voortgang:</span>
|
||||
<div class="flex-1 bg-slate-950 h-2 rounded-full overflow-hidden border border-slate-800">
|
||||
<div class="bg-gradient-to-r from-emerald-500 to-teal-400 h-full transition-all" style="width: <?= $item['progress'] ?>%"></div>
|
||||
<span class="text-slate-500 dark:text-slate-400 text-[11px] font-semibold w-16">Voortgang:</span>
|
||||
<div class="flex-1 bg-slate-100 dark:bg-slate-900 h-2.5 rounded-full overflow-hidden border border-slate-200 dark:border-slate-800">
|
||||
<div class="bg-gradient-to-r from-indigo-600 to-sky-500 h-full transition-all" style="width: <?= $item['progress'] ?>%"></div>
|
||||
</div>
|
||||
<span class="font-mono text-slate-200 font-bold w-10 text-right"><?= $item['progress'] ?>%</span>
|
||||
<span class="font-mono text-slate-800 dark:text-slate-200 font-bold w-10 text-right"><?= $item['progress'] ?>%</span>
|
||||
<div class="flex space-x-1">
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 25)" class="px-1.5 py-0.5 rounded bg-slate-800 text-[10px] text-slate-400 hover:text-white">25%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 50)" class="px-1.5 py-0.5 rounded bg-slate-800 text-[10px] text-slate-400 hover:text-white">50%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 100)" class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-[10px] text-emerald-400 hover:text-emerald-300 font-bold">100%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 25)" class="px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white cursor-pointer font-semibold transition">25%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 50)" class="px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white cursor-pointer font-semibold transition">50%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 100)" class="px-2 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/15 text-[10px] text-indigo-700 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-bold cursor-pointer transition">100%</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deadline & Creator -->
|
||||
<div class="flex items-center space-x-3 text-slate-500 text-[11px]">
|
||||
<?php if (!empty($item['due_date'])): ?>
|
||||
<span class="flex items-center space-x-1 text-slate-300">
|
||||
<i data-lucide="calendar" class="w-3.5 h-3.5 text-emerald-400"></i>
|
||||
<span class="flex items-center space-x-1 text-slate-700 dark:text-slate-300 font-semibold">
|
||||
<i data-lucide="calendar" class="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Deadline: <?= date('d-m-Y', strtotime($item['due_date'])) ?></span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
@@ -194,33 +245,33 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nieuw Roadmap Item Handmatig Toevoegen -->
|
||||
<div id="newItemModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full">
|
||||
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
|
||||
<h3 class="text-base font-bold text-white flex items-center space-x-2">
|
||||
<i data-lucide="map" class="w-4 h-4 text-emerald-400"></i>
|
||||
<div id="newItemModal" onclick="if(event.target === this) this.classList.add('hidden')" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="map" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Nieuw Roadmap Item Toevoegen</span>
|
||||
</h3>
|
||||
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="text-slate-400 hover:text-white">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Titel / Doel</label>
|
||||
<input type="text" id="manualTitle" placeholder="Bijv. 2FA Authenticatie afronden" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Titel / Doel</label>
|
||||
<input type="text" id="manualTitle" placeholder="Bijv. 2FA Authenticatie afronden" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Beschrijving / Specificatie</label>
|
||||
<textarea id="manualDescription" rows="3" placeholder="Wat houdt deze milestone of feature in?" class="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white text-xs placeholder-slate-500 focus:outline-none resize-none"></textarea>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beschrijving / Specificatie</label>
|
||||
<textarea id="manualDescription" rows="3" placeholder="Wat houdt deze milestone of feature in?" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Kwartaal</label>
|
||||
<select id="manualQuarter" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Kwartaal</label>
|
||||
<select id="manualQuarter" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="Q1">Q1</option>
|
||||
<option value="Q2">Q2</option>
|
||||
<option value="Q3" selected>Q3</option>
|
||||
@@ -228,8 +279,8 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Type</label>
|
||||
<select id="manualType" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
||||
<select id="manualType" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="FEATURE" selected>Feature</option>
|
||||
<option value="EPIC">Epic</option>
|
||||
<option value="MILESTONE">Mijlpaal</option>
|
||||
@@ -240,8 +291,8 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Prioriteit</label>
|
||||
<select id="manualPriority" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
|
||||
<select id="manualPriority" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="CRITICAL">Kritiek</option>
|
||||
<option value="HIGH" selected>Hoog</option>
|
||||
<option value="MEDIUM">Normaal</option>
|
||||
@@ -249,21 +300,21 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Jira Issue Key (optioneel)</label>
|
||||
<input type="text" id="manualJiraKey" placeholder="Bijv. FOC-101" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-white text-xs font-mono focus:outline-none">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Jira Issue Key (optioneel)</label>
|
||||
<input type="text" id="manualJiraKey" placeholder="Bijv. FOC-101" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Streefdatum</label>
|
||||
<input type="date" id="manualDueDate" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-white text-xs focus:outline-none">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum</label>
|
||||
<input type="date" id="manualDueDate" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button onclick="submitManualItem()" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition">
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button onclick="submitManualItem()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
|
||||
Toevoegen aan Roadmap
|
||||
</button>
|
||||
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
|
||||
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
@@ -271,6 +322,236 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Roadmap Item Bewerken -->
|
||||
<div id="editRoadmapModal" onclick="if(event.target === this) closeEditRoadmapModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Roadmap Item Bewerken</span>
|
||||
</h3>
|
||||
<button onclick="closeEditRoadmapModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="editRoadmapId">
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Titel / Doel</label>
|
||||
<input type="text" id="editRoadmapTitle" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beschrijving</label>
|
||||
<textarea id="editRoadmapDescription" rows="3" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Kwartaal</label>
|
||||
<select id="editRoadmapQuarter" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="Q1">Q1</option>
|
||||
<option value="Q2">Q2</option>
|
||||
<option value="Q3">Q3</option>
|
||||
<option value="Q4">Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
||||
<select id="editRoadmapType" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="FEATURE">Feature</option>
|
||||
<option value="EPIC">Epic</option>
|
||||
<option value="MILESTONE">Mijlpaal</option>
|
||||
<option value="TASK">Taak</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
|
||||
<select id="editRoadmapPriority" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="CRITICAL">Kritiek</option>
|
||||
<option value="HIGH">Hoog</option>
|
||||
<option value="MEDIUM">Normaal</option>
|
||||
<option value="LOW">Laag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Status</label>
|
||||
<select id="editRoadmapStatus" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="PLANNED">Gepland</option>
|
||||
<option value="IN_PROGRESS">In Uitvoering</option>
|
||||
<option value="REVIEW">In Review</option>
|
||||
<option value="COMPLETED">Voltooid</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Jira Issue Key</label>
|
||||
<input type="text" id="editRoadmapJiraKey" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum</label>
|
||||
<input type="date" id="editRoadmapDueDate" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button id="btnSaveEditRoadmap" onclick="submitEditRoadmapItem()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
|
||||
Opslaan
|
||||
</button>
|
||||
<button onclick="closeEditRoadmapModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Roadmap Item Toewijzen & Inplannen in Vrij Blok -->
|
||||
<div id="roadmapScheduleModal" onclick="if(event.target === this) closeRoadmapScheduleModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-8 h-8 rounded-xl bg-amber-500/20 text-amber-600 dark:text-amber-400 flex items-center justify-center">
|
||||
<i data-lucide="calendar-plus" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Inplannen in Agenda van Collega</h3>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400">Plant dit item direct in een vrij tijdblok van de collega</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="closeRoadmapScheduleModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="roadmapScheduleItemId">
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Item titel preview -->
|
||||
<div class="p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs">
|
||||
<span class="text-[10px] text-slate-400 font-bold uppercase tracking-wider block mb-0.5">Roadmap Item</span>
|
||||
<span id="roadmapScheduleTitlePreview" class="font-bold text-slate-800 dark:text-slate-200"></span>
|
||||
</div>
|
||||
|
||||
<!-- Collega Selector -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Toewijzen aan collega</label>
|
||||
<select id="roadmapAssigneeSelect" onchange="onRoadmapAssigneeChange()" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<?php foreach ($teamMembers as $tm): ?>
|
||||
<option value="<?= $tm['id'] ?>" data-has-outlook="<?= $tm['has_outlook'] ? '1' : '0' ?>" <?= $tm['id'] === $currentUserId ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($tm['name']) ?> (<?= $tm['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>) <?= $tm['has_outlook'] ? '— 📅 Outlook Actief' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Outlook melding -->
|
||||
<div id="roadmapAssignOutlookNotice" class="p-2 rounded-xl bg-sky-50 dark:bg-sky-950/40 border border-sky-200 dark:border-sky-800/60 flex items-center space-x-2 text-[11px] text-sky-700 dark:text-sky-300">
|
||||
<i data-lucide="calendar-check" class="w-4 h-4 text-sky-500 flex-shrink-0"></i>
|
||||
<span>Deze collega heeft Microsoft Outlook gekoppeld. Er wordt gekeken naar echte vrije blokken.</span>
|
||||
</div>
|
||||
|
||||
<!-- Vrij Blok Zoeker -->
|
||||
<div class="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs">
|
||||
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
|
||||
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-500"></i>
|
||||
<span>Automatisch vrij blok vinden</span>
|
||||
</span>
|
||||
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden (09:00 - 17:00)</span>
|
||||
</div>
|
||||
<button type="button" id="btnRoadmapFindSlot" onclick="findFreeSlotForRoadmapAssignee()" class="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-xs shadow-xs transition flex items-center space-x-1.5 flex-shrink-0 cursor-pointer">
|
||||
<i data-lucide="search" class="w-3.5 h-3.5"></i>
|
||||
<span>Zoek vrij blok</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="roadmapFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-xs text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-500 flex-shrink-0"></i>
|
||||
<span id="roadmapFoundSlotText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Datum</label>
|
||||
<input type="date" id="roadmapPlanDate" value="<?= date('Y-m-d') ?>" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Starttijd</label>
|
||||
<input type="time" id="roadmapStartTime" value="10:00" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Duur</label>
|
||||
<select id="roadmapDurationMinutes" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="30">30 minuten</option>
|
||||
<option value="45">45 minuten</option>
|
||||
<option value="60" selected>1 uur</option>
|
||||
<option value="90">1.5 uur</option>
|
||||
<option value="120">2 uur</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center pt-5">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" id="roadmapSyncOutlook" checked class="rounded text-indigo-600 focus:ring-indigo-500">
|
||||
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200">Sync met Outlook 365</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button id="btnSubmitRoadmapSchedule" onclick="submitRoadmapSchedule()" class="flex-1 bg-gradient-to-r from-indigo-600 to-indigo-500 hover:from-indigo-500 hover:to-indigo-400 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
<span>Inplannen in Agenda</span>
|
||||
</button>
|
||||
<button onclick="closeRoadmapScheduleModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechtermuisknop Contextmenu voor Roadmap Items -->
|
||||
<div id="roadmapContextMenu" class="hidden fixed z-50 w-56 bg-white dark:bg-slate-900 rounded-xl shadow-2xl border border-slate-200 dark:border-slate-800 py-1.5 text-xs text-slate-700 dark:text-slate-200">
|
||||
<button onclick="triggerRoadmapCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
|
||||
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
|
||||
<span>Item bewerken</span>
|
||||
</button>
|
||||
|
||||
<button onclick="triggerRoadmapCtxSchedule()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
|
||||
<i data-lucide="calendar-plus" class="w-4 h-4 text-amber-500"></i>
|
||||
<span>👤 Toewijzen & vrij blok inplannen</span>
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
|
||||
|
||||
<div class="px-3.5 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Verplaats naar Kwartaal</div>
|
||||
<div class="grid grid-cols-4 gap-1 px-3 py-1">
|
||||
<button onclick="triggerRoadmapCtxMoveQuarter('Q1')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q1</button>
|
||||
<button onclick="triggerRoadmapCtxMoveQuarter('Q2')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q2</button>
|
||||
<button onclick="triggerRoadmapCtxMoveQuarter('Q3')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q3</button>
|
||||
<button onclick="triggerRoadmapCtxMoveQuarter('Q4')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q4</button>
|
||||
</div>
|
||||
|
||||
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
|
||||
|
||||
<button onclick="triggerRoadmapCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
<span>Item verwijderen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function updateRoadmapStatus(id, status) {
|
||||
await fetch('/api/roadmap.php?action=update_item', {
|
||||
@@ -323,7 +604,370 @@ async function submitManualItem() {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag & Drop van Roadmap Items naar ander Kwartaal (Q1, Q2, Q3, Q4) ---
|
||||
let draggedRoadmapItemId = null;
|
||||
|
||||
function handleRoadmapItemDragStart(e, itemId) {
|
||||
draggedRoadmapItemId = itemId;
|
||||
try {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(itemId));
|
||||
} catch (err) {}
|
||||
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
if (el) el.classList.add('opacity-40', 'scale-[0.99]');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function handleRoadmapItemDragEnd(e) {
|
||||
if (draggedRoadmapItemId) {
|
||||
const el = document.getElementById(`roadmap-item-${draggedRoadmapItemId}`);
|
||||
if (el) el.classList.remove('opacity-40', 'scale-[0.99]');
|
||||
draggedRoadmapItemId = null;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.roadmap-quarter-tab').forEach(tab => {
|
||||
tab.classList.remove('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
|
||||
});
|
||||
}
|
||||
|
||||
function handleRoadmapQuarterDragOver(e) {
|
||||
if (!draggedRoadmapItemId) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const tab = e.currentTarget;
|
||||
if (tab && !tab.classList.contains('ring-2')) {
|
||||
tab.classList.add('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
|
||||
}
|
||||
}
|
||||
|
||||
function handleRoadmapQuarterDragLeave(e) {
|
||||
const tab = e.currentTarget;
|
||||
if (tab && !tab.contains(e.relatedTarget)) {
|
||||
tab.classList.remove('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoadmapQuarterDrop(e, targetQuarter) {
|
||||
e.preventDefault();
|
||||
const tab = e.currentTarget;
|
||||
if (tab) {
|
||||
tab.classList.remove('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
|
||||
}
|
||||
|
||||
const itemId = draggedRoadmapItemId || parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||
draggedRoadmapItemId = null;
|
||||
|
||||
if (itemId) {
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
if (el) el.classList.remove('opacity-40', 'scale-[0.99]', 'pointer-events-none');
|
||||
}
|
||||
document.querySelectorAll('[id^="roadmap-item-"].opacity-40').forEach(el => {
|
||||
el.classList.remove('opacity-40', 'scale-[0.99]', 'pointer-events-none');
|
||||
});
|
||||
|
||||
if (!itemId || !targetQuarter) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/roadmap.php?action=update_item', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ id: itemId, quarter: targetQuarter })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Kon item niet verplaatsen');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fout bij verplaatsen roadmap item:', err);
|
||||
alert('Netwerkfout bij verplaatsen');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rechtermuisknop Contextmenu & Acties voor Roadmap Items ---
|
||||
let activeRoadmapCtxItemId = null;
|
||||
|
||||
function openRoadmapContextMenu(e, itemId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
activeRoadmapCtxItemId = itemId;
|
||||
const menu = document.getElementById('roadmapContextMenu');
|
||||
if (!menu) return;
|
||||
|
||||
menu.classList.remove('hidden');
|
||||
const x = Math.min(e.clientX, window.innerWidth - 240);
|
||||
const y = Math.min(e.clientY, window.innerHeight - 240);
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeRoadmapContextMenu() {
|
||||
const menu = document.getElementById('roadmapContextMenu');
|
||||
if (menu) menu.classList.add('hidden');
|
||||
activeRoadmapCtxItemId = null;
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#roadmapContextMenu')) {
|
||||
closeRoadmapContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
function triggerRoadmapCtxEdit() {
|
||||
if (!activeRoadmapCtxItemId) return;
|
||||
const itemId = activeRoadmapCtxItemId;
|
||||
closeRoadmapContextMenu();
|
||||
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
if (!el) return;
|
||||
|
||||
document.getElementById('editRoadmapId').value = itemId;
|
||||
document.getElementById('editRoadmapTitle').value = el.getAttribute('data-title') || '';
|
||||
document.getElementById('editRoadmapDescription').value = el.getAttribute('data-description') || '';
|
||||
document.getElementById('editRoadmapQuarter').value = el.getAttribute('data-quarter') || 'Q3';
|
||||
document.getElementById('editRoadmapType').value = el.getAttribute('data-type') || 'FEATURE';
|
||||
document.getElementById('editRoadmapPriority').value = el.getAttribute('data-priority') || 'HIGH';
|
||||
document.getElementById('editRoadmapStatus').value = el.getAttribute('data-status') || 'PLANNED';
|
||||
document.getElementById('editRoadmapDueDate').value = el.getAttribute('data-due-date') || '';
|
||||
document.getElementById('editRoadmapJiraKey').value = el.getAttribute('data-jira-key') || '';
|
||||
|
||||
const modal = document.getElementById('editRoadmapModal');
|
||||
modal.classList.remove('hidden');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
document.getElementById('editRoadmapTitle')?.focus();
|
||||
}
|
||||
|
||||
function closeEditRoadmapModal() {
|
||||
document.getElementById('editRoadmapModal')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function submitEditRoadmapItem() {
|
||||
const id = parseInt(document.getElementById('editRoadmapId').value, 10);
|
||||
const title = document.getElementById('editRoadmapTitle').value.trim();
|
||||
const description = document.getElementById('editRoadmapDescription').value.trim();
|
||||
const quarter = document.getElementById('editRoadmapQuarter').value;
|
||||
const type = document.getElementById('editRoadmapType').value;
|
||||
const priority = document.getElementById('editRoadmapPriority').value;
|
||||
const status = document.getElementById('editRoadmapStatus').value;
|
||||
const due_date = document.getElementById('editRoadmapDueDate').value;
|
||||
const jira_key = document.getElementById('editRoadmapJiraKey').value.trim();
|
||||
|
||||
if (!title) {
|
||||
alert('Titel is verplicht');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnSaveEditRoadmap');
|
||||
btn.disabled = true;
|
||||
btn.innerText = 'Opslaan...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/roadmap.php?action=update_item', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
quarter,
|
||||
type,
|
||||
priority,
|
||||
status,
|
||||
due_date,
|
||||
jira_key
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
closeEditRoadmapModal();
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Kon item niet opslaan');
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Netwerkfout bij opslaan');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerText = 'Opslaan';
|
||||
}
|
||||
}
|
||||
|
||||
function triggerRoadmapCtxSchedule() {
|
||||
if (!activeRoadmapCtxItemId) return;
|
||||
const itemId = activeRoadmapCtxItemId;
|
||||
closeRoadmapContextMenu();
|
||||
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
if (!el) return;
|
||||
|
||||
document.getElementById('roadmapScheduleItemId').value = itemId;
|
||||
document.getElementById('roadmapScheduleTitlePreview').innerText = el.getAttribute('data-title') || 'Roadmap item';
|
||||
document.getElementById('roadmapFoundSlotNotice').classList.add('hidden');
|
||||
|
||||
onRoadmapAssigneeChange();
|
||||
|
||||
const modal = document.getElementById('roadmapScheduleModal');
|
||||
modal.classList.remove('hidden');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
setTimeout(() => {
|
||||
findFreeSlotForRoadmapAssignee();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function closeRoadmapScheduleModal() {
|
||||
document.getElementById('roadmapScheduleModal')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function onRoadmapAssigneeChange() {
|
||||
const sel = document.getElementById('roadmapAssigneeSelect');
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
const notice = document.getElementById('roadmapAssignOutlookNotice');
|
||||
if (opt && opt.getAttribute('data-has-outlook') === '1') {
|
||||
notice.classList.remove('hidden');
|
||||
} else {
|
||||
notice.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function findFreeSlotForRoadmapAssignee() {
|
||||
const sel = document.getElementById('roadmapAssigneeSelect');
|
||||
const targetUserId = sel.value ? parseInt(sel.value, 10) : <?= $currentUserId ?>;
|
||||
const duration = parseInt(document.getElementById('roadmapDurationMinutes').value, 10) || 60;
|
||||
|
||||
const btn = document.getElementById('btnRoadmapFindSlot');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Zoeken...</span>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/planner.php?action=find_next_free_slot', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
duration_minutes: duration,
|
||||
target_user_id: targetUserId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.slot) {
|
||||
const slot = data.slot;
|
||||
document.getElementById('roadmapPlanDate').value = slot.date;
|
||||
document.getElementById('roadmapStartTime').value = slot.start;
|
||||
|
||||
const notice = document.getElementById('roadmapFoundSlotNotice');
|
||||
const noticeText = document.getElementById('roadmapFoundSlotText');
|
||||
noticeText.innerHTML = `Vrij agendablok gevonden: <strong>${slot.formatted_date}</strong> om <strong>${slot.start} – ${slot.end}</strong>`;
|
||||
notice.classList.remove('hidden');
|
||||
} else {
|
||||
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen werktijden.');
|
||||
}
|
||||
} catch(err) {
|
||||
alert('Netwerkfout bij zoeken van vrij agendablok: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRoadmapSchedule() {
|
||||
const itemId = parseInt(document.getElementById('roadmapScheduleItemId').value, 10);
|
||||
const assigneeId = document.getElementById('roadmapAssigneeSelect').value ? parseInt(document.getElementById('roadmapAssigneeSelect').value, 10) : null;
|
||||
const planDate = document.getElementById('roadmapPlanDate').value;
|
||||
const startTime = document.getElementById('roadmapStartTime').value;
|
||||
const durationMinutes = parseInt(document.getElementById('roadmapDurationMinutes').value, 10) || 60;
|
||||
const syncOutlook = document.getElementById('roadmapSyncOutlook').checked;
|
||||
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
const title = el ? el.getAttribute('data-title') : 'Roadmap Item';
|
||||
|
||||
const btn = document.getElementById('btnSubmitRoadmapSchedule');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Inplannen...</span>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
try {
|
||||
// Maak agenda taak aan via api/planner.php
|
||||
const res = await fetch('/api/planner.php?action=add_task', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
date: planDate,
|
||||
title: title,
|
||||
start_time: startTime,
|
||||
duration: durationMinutes,
|
||||
type: 'PROJECT',
|
||||
priority: 'MEDIUM',
|
||||
sync_to_microsoft: syncOutlook,
|
||||
target_user_id: assigneeId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
closeRoadmapScheduleModal();
|
||||
let msg = `Roadmap item is succesvol ingepland op ${planDate} van ${startTime} tot ${data.task?.end_time || ''}!`;
|
||||
if (data.outlook_message) {
|
||||
msg += `\n${data.outlook_message}`;
|
||||
}
|
||||
alert(msg);
|
||||
} else {
|
||||
alert(data.error || 'Kon afspraak niet inplannen');
|
||||
}
|
||||
} catch(err) {
|
||||
alert('Netwerkfout bij inplannen: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerRoadmapCtxMoveQuarter(targetQuarter) {
|
||||
if (!activeRoadmapCtxItemId) return;
|
||||
const itemId = activeRoadmapCtxItemId;
|
||||
closeRoadmapContextMenu();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/roadmap.php?action=update_item', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ id: itemId, quarter: targetQuarter })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Kon item niet verplaatsen');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Netwerkfout bij verplaatsen item');
|
||||
}
|
||||
}
|
||||
|
||||
function triggerRoadmapCtxDelete() {
|
||||
if (!activeRoadmapCtxItemId) return;
|
||||
const itemId = activeRoadmapCtxItemId;
|
||||
closeRoadmapContextMenu();
|
||||
deleteRoadmapItem(itemId);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('newItemModal');
|
||||
if (modal && !modal.classList.contains('hidden')) modal.classList.add('hidden');
|
||||
closeEditRoadmapModal();
|
||||
closeRoadmapScheduleModal();
|
||||
closeRoadmapContextMenu();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
require_once __DIR__ . '/includes/mailer.php';
|
||||
$adminUser = require_admin();
|
||||
$currentUser = $adminUser;
|
||||
$db = get_db_connection();
|
||||
@@ -12,8 +13,14 @@ $message = null;
|
||||
$generatedLink = null;
|
||||
$error = null;
|
||||
|
||||
// Uitnodiging aanmaken
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_invite') {
|
||||
// POST acties afhandelen met CSRF-bescherming
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
$csrfToken = $_POST['csrf_token'] ?? null;
|
||||
|
||||
if (!verify_csrf_token($csrfToken)) {
|
||||
$error = "Beveiligingsfout: CSRF-token ongeldig of verlopen. Ververs de pagina en probeer opnieuw.";
|
||||
} elseif ($action === 'create_invite') {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$role = in_array($_POST['role'] ?? '', ['ADMIN', 'MEMBER']) ? $_POST['role'] : 'MEMBER';
|
||||
|
||||
@@ -29,79 +36,122 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
||||
$token = create_invite($adminUser['id'], $email, $role);
|
||||
$baseUrl = rtrim(APP_URL, '/');
|
||||
$generatedLink = "{$baseUrl}/auth/invite.php?token={$token}";
|
||||
$message = "Uitnodigingslink succesvol aangemaakt!";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Uitnodiging intrekken
|
||||
if (isset($_GET['delete_invite'])) {
|
||||
$inviteId = (int)$_GET['delete_invite'];
|
||||
// Verstuur de gestylede uitnodigingsmail direct via SMTP
|
||||
$mailResult = FocizMailer::sendInvitationEmail($email, $adminUser['name'], $role, $generatedLink);
|
||||
fociz_log_audit((int)$adminUser['id'], 'USER_INVITED', 'invite', null, ['email' => $email, 'role' => $role], 'SUCCESS', $adminUser['email']);
|
||||
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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($action === 'resend_invite') {
|
||||
$inviteId = (int)($_POST['invite_id'] ?? 0);
|
||||
$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);
|
||||
fociz_log_audit((int)$adminUser['id'], 'INVITE_RESENT', 'invite', $inviteId, ['email' => $inv['email'], 'role' => $inv['role']], 'SUCCESS', $adminUser['email']);
|
||||
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.";
|
||||
}
|
||||
} elseif ($action === 'delete_invite') {
|
||||
$inviteId = (int)($_POST['invite_id'] ?? 0);
|
||||
$stmtDel = $db->prepare("DELETE FROM invites WHERE id = ?");
|
||||
$stmtDel->execute([$inviteId]);
|
||||
fociz_log_audit((int)$adminUser['id'], 'INVITE_REVOKED', 'invite', $inviteId, [], 'SUCCESS', $adminUser['email']);
|
||||
header('Location: /users.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Gebruiker verwijderen
|
||||
if (isset($_GET['delete_user'])) {
|
||||
$delUserId = (int)$_GET['delete_user'];
|
||||
} elseif ($action === 'delete_user') {
|
||||
$delUserId = (int)($_POST['user_id'] ?? 0);
|
||||
if ($delUserId !== (int)$adminUser['id']) {
|
||||
$stmtUserToDel = $db->prepare("SELECT email, name FROM users WHERE id = ?");
|
||||
$stmtUserToDel->execute([$delUserId]);
|
||||
$delUser = $stmtUserToDel->fetch();
|
||||
|
||||
$stmtDelUser = $db->prepare("DELETE FROM users WHERE id = ?");
|
||||
$stmtDelUser->execute([$delUserId]);
|
||||
try {
|
||||
$db->prepare("DELETE FROM user_sessions WHERE user_id = ?")->execute([$delUserId]);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
fociz_log_audit((int)$adminUser['id'], 'USER_DELETED', 'user', $delUserId, ['deleted_email' => $delUser['email'] ?? null, 'deleted_name' => $delUser['name'] ?? null], 'SUCCESS', $adminUser['email']);
|
||||
}
|
||||
header('Location: /users.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Haal alle gebruikers op
|
||||
$users = $db->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll();
|
||||
$users = [];
|
||||
try {
|
||||
$users = $db->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen gebruikers: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Haal alle uitnodigingen op
|
||||
$invites = $db->query("
|
||||
$invites = [];
|
||||
try {
|
||||
$invites = $db->query("
|
||||
SELECT i.*, u.name as inviter_name
|
||||
FROM invites i
|
||||
JOIN users u ON i.created_by_id = u.id
|
||||
ORDER BY i.created_at DESC
|
||||
")->fetchAll();
|
||||
")->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen uitnodigingen: " . $e->getMessage());
|
||||
ensure_tables_exist($db);
|
||||
}
|
||||
|
||||
$pageTitle = 'Team & Uitnodigingen — Fociz';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-6 max-w-7xl mx-auto pb-12">
|
||||
<!-- Header -->
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
|
||||
<div class="flex items-center space-x-2 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-1">
|
||||
<div class="fociz-card p-6 sm:p-7">
|
||||
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<i data-lucide="shield" class="w-4 h-4"></i>
|
||||
<span>Beheerderspaneel</span>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
|
||||
Team & Uitnodigingen
|
||||
</h1>
|
||||
<p class="text-slate-400 text-xs sm:text-sm mt-1">
|
||||
Fociz is strikt afgesloten: nieuwe collega's kunnen enkel toegang krijgen na het ontvangen van een uitnodigingslink en verplichte 2FA-activatie.
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
||||
Beheer collega's, rollen en uitnodigingslinks voor toegang tot de Fociz omgeving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-xs font-semibold flex items-center space-x-2">
|
||||
<div class="p-4 rounded-2xl bg-red-500/10 border border-red-500/30 text-red-700 dark:text-red-400 text-xs font-semibold flex items-center space-x-2">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
|
||||
<span><?= htmlspecialchars($error) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($message && $generatedLink): ?>
|
||||
<div class="p-5 rounded-2xl bg-emerald-950/40 border border-emerald-500/40 text-white space-y-2 shadow-xl">
|
||||
<div class="flex items-center space-x-2 text-emerald-400 font-bold text-xs">
|
||||
<div class="p-5 rounded-2xl bg-teal-50/80 dark:bg-teal-950/30 border border-teal-200 dark:border-teal-800/60 text-slate-900 dark:text-white space-y-2 shadow-xs transition">
|
||||
<div class="flex items-center space-x-2 text-teal-700 dark:text-teal-400 font-bold text-xs">
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
|
||||
<span><?= htmlspecialchars($message) ?></span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-300">
|
||||
<p class="text-xs text-slate-600 dark:text-slate-300">
|
||||
Kopieer onderstaande beveiligde registratielink en stuur deze naar de collega:
|
||||
</p>
|
||||
<div class="flex items-center space-x-2 bg-slate-950 p-2.5 rounded-xl border border-slate-800">
|
||||
<input type="text" readonly id="inviteLinkInput" value="<?= htmlspecialchars($generatedLink) ?>" class="bg-transparent text-emerald-300 font-mono text-xs flex-1 outline-none">
|
||||
<button onclick="copyInviteLink()" class="px-3 py-1.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold text-xs rounded-lg transition flex items-center space-x-1">
|
||||
<div class="flex items-center space-x-2 bg-white dark:bg-[#0c0f17] p-2.5 rounded-xl border border-teal-200 dark:border-slate-800">
|
||||
<input type="text" readonly id="inviteLinkInput" value="<?= htmlspecialchars($generatedLink) ?>" class="bg-transparent text-teal-700 dark:text-teal-300 font-mono text-xs flex-1 outline-none">
|
||||
<button onclick="copyInviteLink()" class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition flex items-center space-x-1 cursor-pointer">
|
||||
<i data-lucide="copy" class="w-3.5 h-3.5"></i>
|
||||
<span id="copyBtnText">Kopiëren</span>
|
||||
</button>
|
||||
@@ -110,30 +160,31 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Uitnodiging Genereren Kaart -->
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
|
||||
<h3 class="text-base font-bold text-white mb-4 flex items-center space-x-2">
|
||||
<i data-lucide="user-plus" class="w-5 h-5 text-emerald-400"></i>
|
||||
<div class="fociz-card p-6 sm:p-7">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white mb-4 flex items-center space-x-2">
|
||||
<i data-lucide="user-plus" class="w-5 h-5 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Nieuwe Collega Uitnodigen</span>
|
||||
</h3>
|
||||
|
||||
<form method="POST" action="/users.php" class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
|
||||
<input type="hidden" name="action" value="create_invite">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="md:col-span-6">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">E-mailadres van collega</label>
|
||||
<input type="email" name="email" required placeholder="collega@bedrijf.nl" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 text-xs">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres van collega</label>
|
||||
<input type="email" name="email" required placeholder="collega@bedrijf.nl" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 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-xs shadow-2xs">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Rol</label>
|
||||
<select name="role" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-white text-xs focus:outline-none">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Rol</label>
|
||||
<select name="role" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="MEMBER">Collega (Standaard)</option>
|
||||
<option value="ADMIN">Mede-beheerder</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<button type="submit" class="w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<button type="submit" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<i data-lucide="link" class="w-4 h-4"></i>
|
||||
<span>Genereer Link</span>
|
||||
</button>
|
||||
@@ -142,17 +193,17 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
|
||||
<!-- Actieve Gebruikers Tabel -->
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center space-x-2">
|
||||
<i data-lucide="users" class="w-4 h-4 text-emerald-400"></i>
|
||||
<div class="fociz-card p-6 sm:p-7 space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider flex items-center space-x-2">
|
||||
<i data-lucide="users" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Geregistreerde Teamleden (<?= count($users) ?>)</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs text-slate-300">
|
||||
<thead class="bg-slate-950/60 text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-800">
|
||||
<table class="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||
<thead class="bg-slate-50 dark:bg-[#0c0f17] text-slate-500 dark:text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-200 dark:border-slate-800">
|
||||
<tr>
|
||||
<th class="p-3">Naam & E-mail</th>
|
||||
<th class="p-3">Rol</th>
|
||||
@@ -161,34 +212,55 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<th class="p-3 text-right">Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/60">
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/60">
|
||||
<?php foreach ($users as $u): ?>
|
||||
<tr class="hover:bg-slate-800/30 transition">
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
|
||||
<td class="p-3">
|
||||
<div class="font-bold text-white"><?= htmlspecialchars($u['name']) ?></div>
|
||||
<div class="text-[11px] text-slate-400 font-mono"><?= htmlspecialchars($u['email']) ?></div>
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 overflow-hidden shadow-2xs">
|
||||
<?php if (!empty($u['avatar_url'])): ?>
|
||||
<img src="<?= htmlspecialchars($u['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-[10px] object-cover bg-white dark:bg-slate-900" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
|
||||
<div class="hidden w-full h-full rounded-[10px] bg-white dark:bg-slate-900 flex items-center justify-center font-bold text-xs text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($u['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="w-full h-full rounded-[10px] bg-white dark:bg-slate-900 flex items-center justify-center font-bold text-xs text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($u['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($u['name']) ?></div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 font-mono"><?= htmlspecialchars($u['email']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $u['role'] === 'ADMIN' ? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30' : 'bg-slate-800 text-slate-300' ?>">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $u['role'] === 'ADMIN' ? 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300' ?>">
|
||||
<?= $u['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[10px] font-medium flex items-center space-x-1 w-max">
|
||||
<span class="px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60 text-[10px] font-medium flex items-center space-x-1 w-max">
|
||||
<i data-lucide="shield-check" class="w-3 h-3"></i>
|
||||
<span>2FA Actief</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3 text-slate-400">
|
||||
<td class="p-3 text-slate-500 dark:text-slate-400">
|
||||
<?= date('d-m-Y', strtotime($u['created_at'])) ?>
|
||||
</td>
|
||||
<td class="p-3 text-right">
|
||||
<?php if ($u['id'] !== $adminUser['id']): ?>
|
||||
<a href="/users.php?delete_user=<?= $u['id'] ?>" onclick="return confirm('Gebruiker verwijderen?')" class="text-slate-500 hover:text-red-400 transition p-1">
|
||||
<form method="POST" action="/users.php" class="inline" onsubmit="return confirm('Weet u zeker dat u deze gebruiker wilt verwijderen?')">
|
||||
<input type="hidden" name="action" value="delete_user">
|
||||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" title="Gebruiker verwijderen" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer bg-transparent border-0">
|
||||
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
|
||||
</a>
|
||||
</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<span class="text-[10px] text-slate-600 font-medium">Uzelf</span>
|
||||
<span class="text-[10px] text-slate-400 dark:text-slate-600 font-medium">Uzelf</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -199,17 +271,17 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
|
||||
<!-- Uitstaande Uitnodigingen Tabel -->
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center space-x-2">
|
||||
<i data-lucide="mail" class="w-4 h-4 text-amber-400"></i>
|
||||
<div class="fociz-card p-6 sm:p-7 space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider flex items-center space-x-2">
|
||||
<i data-lucide="mail" class="w-4 h-4 text-amber-500"></i>
|
||||
<span>Uitnodigingen Status (<?= count($invites) ?>)</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs text-slate-300">
|
||||
<thead class="bg-slate-950/60 text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-800">
|
||||
<table class="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||
<thead class="bg-slate-50 dark:bg-[#0c0f17] text-slate-500 dark:text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-200 dark:border-slate-800">
|
||||
<tr>
|
||||
<th class="p-3">Uitgenodigd E-mailadres</th>
|
||||
<th class="p-3">Rol</th>
|
||||
@@ -218,31 +290,53 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<th class="p-3 text-right">Actie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/60">
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/60">
|
||||
<?php if (empty($invites)): ?>
|
||||
<tr><td colspan="5" class="p-4 text-center text-slate-500">Nog geen uitnodigingen verstuurd.</td></tr>
|
||||
<tr><td colspan="5" class="p-4 text-center text-slate-400 dark:text-slate-500">Nog geen uitnodigingen verstuurd.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($invites as $inv):
|
||||
$isUsed = !empty($inv['used_at']);
|
||||
$isExpired = strtotime($inv['expires_at']) < time();
|
||||
?>
|
||||
<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="p-3 font-mono"><?= htmlspecialchars($inv['email']) ?></td>
|
||||
<td class="p-3"><?= htmlspecialchars($inv['role']) ?></td>
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
|
||||
<td class="p-3 font-mono text-slate-900 dark:text-white"><?= htmlspecialchars($inv['email']) ?></td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $inv['role'] === 'ADMIN' ? 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300' ?>">
|
||||
<?= $inv['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<?php if ($isUsed): ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400">Geaccepteerd</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-teal-50 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60">Geaccepteerd</span>
|
||||
<?php elseif ($isExpired): ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-400">Verlopen</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/15 text-red-700 dark:text-red-400 border border-red-500/30">Verlopen</span>
|
||||
<?php else: ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400">In afwachting</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/30">In afwachting</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="p-3 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">
|
||||
<a href="/users.php?delete_invite=<?= $inv['id'] ?>" onclick="return confirm('Uitnodiging intrekken?')" class="text-slate-500 hover:text-red-400 transition p-1">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<?php if (!$isUsed && !$isExpired): ?>
|
||||
<form method="POST" action="/users.php" class="inline">
|
||||
<input type="hidden" name="action" value="resend_invite">
|
||||
<input type="hidden" name="invite_id" value="<?= (int)$inv['id'] ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" 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 border-0">
|
||||
<i data-lucide="mail" class="w-3.5 h-3.5"></i>
|
||||
<span>Opnieuw mailen</span>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<form method="POST" action="/users.php" class="inline" onsubmit="return confirm('Weet u zeker dat u deze uitnodiging wilt intrekken?')">
|
||||
<input type="hidden" name="action" value="delete_invite">
|
||||
<input type="hidden" name="invite_id" value="<?= (int)$inv['id'] ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" 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 bg-transparent border-0">
|
||||
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
|
||||
</a>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
@@ -266,4 +360,3 @@ function copyInviteLink() {
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
|
||||