Compare commits
184 Commits
4fbe30b4b6
..
dev
| 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 |
@@ -1,6 +1,8 @@
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
/.env
|
||||
/.env.local
|
||||
*.local
|
||||
!config/.env
|
||||
|
||||
# Systeem & Editor
|
||||
.DS_Store
|
||||
@@ -10,3 +12,7 @@ Thumbs.db
|
||||
.idea/
|
||||
.continue/
|
||||
.continueignore
|
||||
|
||||
# Uploads
|
||||
/assets/uploads/*
|
||||
!/assets/uploads/**/.gitkeep
|
||||
|
||||
@@ -43,7 +43,7 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards);
|
||||
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
@@ -72,6 +72,7 @@ try {
|
||||
$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']);
|
||||
@@ -87,22 +88,76 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count);
|
||||
$authorName = 'AI Co-Pilot';
|
||||
$createdCards = [];
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
$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)
|
||||
");
|
||||
|
||||
$createdCards = [];
|
||||
$authorName = 'AI Co-Pilot';
|
||||
|
||||
foreach ($aiRes['ideas'] as $ideaText) {
|
||||
foreach ($ideas as $ideaText) {
|
||||
$ideaText = trim($ideaText);
|
||||
if (!empty($ideaText)) {
|
||||
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
|
||||
@@ -117,7 +172,7 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'cards' => $createdCards]);
|
||||
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'ideas']);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -132,7 +187,7 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle);
|
||||
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
@@ -143,7 +198,298 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. Slimme dagindeling berekenen voor taken rondom Outlook afspraken
|
||||
// 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');
|
||||
@@ -154,27 +500,46 @@ try {
|
||||
$plan = $stmtPlan->fetch();
|
||||
$planId = $plan['id'] ?? 0;
|
||||
|
||||
// Haal openstaande taken op voor de dag (inclusief fallback voor ongebonden taken op de huidige dag)
|
||||
// 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
|
||||
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 taken gevonden om in te delen']);
|
||||
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';
|
||||
$events = microsoft_fetch_today_events($user['id'], $date);
|
||||
$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, $events, $workdaySettings);
|
||||
$aiRes = fociz_ai_smart_schedule($tasks, $busyBlocks, $workdaySettings, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
@@ -205,8 +570,110 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende actie']);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
// 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()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -274,7 +274,9 @@ try {
|
||||
$stmt = $db->prepare("UPDATE board_lanes SET title = ?, color = ? WHERE id = ? AND board_id = ?");
|
||||
$stmt->execute([$title, $color, $laneId, $boardId]);
|
||||
} else {
|
||||
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_lanes WHERE board_id = {$boardId}")->fetchColumn();
|
||||
$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();
|
||||
@@ -296,7 +298,9 @@ try {
|
||||
}
|
||||
|
||||
// Controleer of er kaarten in staan
|
||||
$cardCount = (int)$db->query("SELECT COUNT(*) FROM board_cards WHERE lane_id = {$laneId}")->fetchColumn();
|
||||
$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;
|
||||
@@ -337,7 +341,9 @@ try {
|
||||
");
|
||||
$stmt->execute([$title, $description, $assigneeId, $priority, $color, $laneId, $cardId, $boardId]);
|
||||
} else {
|
||||
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = {$laneId}")->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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -385,7 +391,7 @@ try {
|
||||
|
||||
$resCreate = microsoft_create_event($assigneeId, $eventPayload);
|
||||
if (!empty($resCreate['success'])) {
|
||||
$outlookEventId = $resCreate['id'] ?? null;
|
||||
$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 {
|
||||
@@ -431,7 +437,9 @@ try {
|
||||
$targetLaneId = (int)($input['target_lane_id'] ?? 0);
|
||||
$targetPosition = (int)($input['target_position'] ?? 0);
|
||||
|
||||
$card = $db->query("SELECT * FROM board_cards WHERE id = {$cardId}")->fetch();
|
||||
$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;
|
||||
@@ -443,6 +451,15 @@ try {
|
||||
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]);
|
||||
|
||||
@@ -486,6 +503,105 @@ try {
|
||||
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());
|
||||
|
||||
@@ -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,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,10 @@
|
||||
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;
|
||||
@@ -408,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 |
@@ -52,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']);
|
||||
@@ -61,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)) {
|
||||
@@ -136,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>
|
||||
|
||||
@@ -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,15 +75,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$db->commit();
|
||||
|
||||
// Start dagsessie (geldig tot 23:59:59)
|
||||
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;
|
||||
$_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');
|
||||
@@ -105,6 +119,8 @@ $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() {
|
||||
@@ -170,12 +186,12 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<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">
|
||||
<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-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<input type="password" name="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>
|
||||
|
||||
@@ -189,7 +205,7 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
</p>
|
||||
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-40 h-40 mx-auto">
|
||||
<div id="qrcode" class="w-40 h-40 mx-auto flex items-center justify-center"></div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -211,8 +227,20 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
</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) {
|
||||
@@ -22,27 +34,76 @@ if ($currentUser) {
|
||||
$error = null;
|
||||
$infoMessage = null;
|
||||
if (isset($_GET['expired'])) {
|
||||
$infoMessage = "Uw dagsessie is om 23:59 beëindigd. Log opnieuw in om een nieuwe werkdag te starten.";
|
||||
$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 = ?");
|
||||
@@ -50,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.";
|
||||
}
|
||||
}
|
||||
@@ -70,7 +156,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($totpCode)) {
|
||||
$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 * FROM users WHERE id = ?");
|
||||
@@ -104,24 +195,60 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
if ($isValid) {
|
||||
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();
|
||||
|
||||
// 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.";
|
||||
}
|
||||
}
|
||||
@@ -136,6 +263,8 @@ $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() {
|
||||
@@ -207,6 +336,7 @@ $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-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres</label>
|
||||
<div class="relative">
|
||||
@@ -215,7 +345,10 @@ $pageTitle = "Fociz — Inloggen";
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord</label>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Wachtwoord</label>
|
||||
<a href="/auth/forgot-password.php" class="text-xs text-indigo-600 dark:text-indigo-400 hover:underline font-medium">Wachtwoord vergeten?</a>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<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>
|
||||
@@ -231,6 +364,30 @@ $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-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>
|
||||
@@ -244,10 +401,7 @@ $pageTitle = "Fociz — Inloggen";
|
||||
</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-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">
|
||||
<span class="text-[11px] text-slate-500 dark: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">
|
||||
@@ -269,6 +423,10 @@ $pageTitle = "Fociz — Inloggen";
|
||||
</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>
|
||||
|
||||
@@ -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,17 +62,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$adminId = (int)$db->lastInsertId();
|
||||
|
||||
// Automatische dagsessie starten (geldig tot 23:59:59)
|
||||
// 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;
|
||||
$_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();
|
||||
@@ -90,6 +104,8 @@ $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() {
|
||||
@@ -191,12 +207,12 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<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">
|
||||
<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-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<input type="password" name="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>
|
||||
@@ -212,7 +228,7 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
</p>
|
||||
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-44 h-44 mx-auto">
|
||||
<div id="qrcode" class="w-44 h-44 mx-auto flex items-center justify-center"></div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -236,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>
|
||||
@@ -13,20 +13,77 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
?>
|
||||
|
||||
<style>
|
||||
/* Soepele overgang tussen standaard en gemaximaliseerde browserbreedte */
|
||||
#boardMainWrapper {
|
||||
transition: max-width 0.35s cubic-bezier(0.16, 1, 0.3, 1), padding 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
/* Soepele overgang voor de lanes container bij maximaliseren */
|
||||
#lanesSectionWrapper {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
#boardMainWrapper.is-maximized {
|
||||
#lanesSectionWrapper.is-maximized {
|
||||
max-width: 100% !important;
|
||||
padding-left: 1.25rem !important;
|
||||
padding-right: 1.25rem !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* --- Strakke, Moderne & Professionele Post-it Sticky Note Styling --- */
|
||||
.postit-note {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||
0 4px 10px -2px rgba(0, 0, 0, 0.08),
|
||||
0 12px 20px -4px rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.postit-note:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow:
|
||||
0 3px 6px rgba(0, 0, 0, 0.06),
|
||||
0 8px 18px -2px rgba(0, 0, 0, 0.12),
|
||||
0 16px 28px -4px rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
/* Strakke, semi-transparante plakstrip (tape) bovenaan het briefje */
|
||||
.postit-note::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 54px;
|
||||
height: 13px;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dark .postit-note::before {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Subtiele plakrand glans bovenaan de post-it zelf */
|
||||
.postit-note::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.04), transparent);
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="boardMainWrapper" class="space-y-5 max-w-[1600px] mx-auto px-2 sm:px-4">
|
||||
<div id="boardPageContainer" class="space-y-4 w-full">
|
||||
|
||||
<!-- Bovenbalk: Bord Titel, Filters & Actieknoppen -->
|
||||
<!-- Bovenbalk: Bord Titel, Filters & Actieknoppen (ALTIJD vaste standaard breedte) -->
|
||||
<div class="max-w-7xl mx-auto w-full">
|
||||
<div class="fociz-card p-4 sm:p-5">
|
||||
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
||||
|
||||
@@ -44,12 +101,22 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
Beheerder
|
||||
</span>
|
||||
</div>
|
||||
<p id="boardDescriptionText" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Teamtaken, opdrachten & Outlook agendablokkering
|
||||
</p>
|
||||
<p id="boardDescriptionText" class="hidden text-xs text-slate-500 dark:text-slate-400 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weergave-schakelaar: Bord (Kanban) / Tijdlijn (Gantt) -->
|
||||
<div class="inline-flex p-1 bg-slate-100/90 dark:bg-[#0c0f17] border border-slate-200/90 dark:border-slate-800/90 rounded-xl text-xs font-semibold shadow-2xs self-start xl:self-center">
|
||||
<span class="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 text-indigo-600 dark:text-indigo-400 font-bold shadow-2xs cursor-default">
|
||||
<i data-lucide="kanban" class="w-3.5 h-3.5"></i>
|
||||
<span>Bord</span>
|
||||
</span>
|
||||
<a id="linkToTimeline" href="/timeline.php" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200/60 dark:hover:bg-slate-800/60 transition">
|
||||
<i data-lucide="clock" class="w-3.5 h-3.5"></i>
|
||||
<span>Tijdlijn</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Midden & Rechts: Filters & Actieknoppen -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -65,10 +132,10 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
<!-- Filter: Prioriteit -->
|
||||
<div class="relative">
|
||||
<select id="filterPriority" onchange="renderCards()" class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium focus:ring-2 focus:ring-indigo-500/30 cursor-pointer shadow-2xs">
|
||||
<option value="ALL">Alle prioriteiten</option>
|
||||
<option value="P1">P1 — Urgent</option>
|
||||
<option value="P2">P2 — Hoog</option>
|
||||
<option value="P3">P3 — Normaal</option>
|
||||
<option value="ALL">Prioriteiten (Alle)</option>
|
||||
<option value="P1">P1 Urgent</option>
|
||||
<option value="P2">P2 Hoog</option>
|
||||
<option value="P3">P3 Normaal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -84,17 +151,6 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Knop: Maximaliseren / Volledige breedte (Toggle) -->
|
||||
<button
|
||||
id="btnToggleMaximize"
|
||||
onclick="toggleBoardMaximize()"
|
||||
title="Bord maximaliseren naar volledige schermbreedte"
|
||||
class="px-2.5 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800/80 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold transition cursor-pointer shadow-2xs flex items-center gap-1.5"
|
||||
>
|
||||
<i id="maximizeIcon" data-lucide="maximize-2" class="w-3.5 h-3.5"></i>
|
||||
<span id="maximizeText" class="hidden sm:inline text-xs font-medium">Maximaliseren</span>
|
||||
</button>
|
||||
|
||||
<!-- Beheer knoppen -->
|
||||
<button
|
||||
id="btnManageBoard"
|
||||
@@ -127,9 +183,26 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board Container (Horizontaal scrollbaar indien te smal, past automatisch in de breedte) -->
|
||||
<div id="boardLanesContainer" class="flex gap-4 overflow-x-auto pb-8 pt-1 min-h-[650px] items-start select-none w-full transition-all duration-300">
|
||||
<!-- Lanes Sectie: Wisselt tussen standaard gecentreerde breedte (max-w-7xl) en volledig scherm (max-w-none w-full) -->
|
||||
<div id="lanesSectionWrapper" class="max-w-7xl mx-auto w-full transition-all duration-300">
|
||||
|
||||
<!-- Strook direct boven de lane blokken: klein maximaliseer-icoon rechts -->
|
||||
<div class="flex items-center justify-between px-1 mb-2">
|
||||
<div class="text-[11px] font-semibold text-slate-500 dark:text-slate-400" id="boardLanesSummary"></div>
|
||||
<button
|
||||
id="btnToggleMaximize"
|
||||
onclick="toggleBoardMaximize()"
|
||||
title="Kolommen maximaliseren naar volledige vensterbreedte"
|
||||
class="w-7 h-7 rounded-lg bg-white dark:bg-[#111624] hover:bg-slate-100 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-300 flex items-center justify-center transition cursor-pointer shadow-2xs hover:scale-105 active:scale-95 ml-auto"
|
||||
>
|
||||
<i id="maximizeIcon" data-lucide="maximize-2" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board Container (past zich in standaardmodus soepel aan zonder scrollbalk) -->
|
||||
<div id="boardLanesContainer" class="flex gap-3 overflow-x-auto pb-2 pt-1 min-h-[440px] items-start select-none w-full transition-all duration-300">
|
||||
<div class="flex items-center justify-center w-full py-20 text-slate-400 text-sm">
|
||||
<i data-lucide="loader-2" class="w-5 h-5 animate-spin mr-2"></i>
|
||||
<span>Bord laden...</span>
|
||||
@@ -138,8 +211,10 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- MODAL 1: Taak (Post-it / Opdracht) Toevoegen of Bewerken -->
|
||||
<div id="cardModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div id="cardModal" onclick="if(event.target === this) closeCardModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="fociz-card p-6 w-full max-w-xl shadow-2xl space-y-5 my-8">
|
||||
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||||
@@ -257,6 +332,25 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Knop Vrij Blok Zoeken voor Assignee -->
|
||||
<div class="p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between gap-2">
|
||||
<div class="text-[11px]">
|
||||
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
|
||||
<i data-lucide="sparkles" class="w-3 h-3 text-amber-500"></i>
|
||||
<span>Vrij agendablok zoeken</span>
|
||||
</span>
|
||||
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden van de toegewezen collega</span>
|
||||
</div>
|
||||
<button type="button" id="btnBoardFindSlot" onclick="findFreeSlotForBoardAssignee()" class="px-2.5 py-1 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-[10px] shadow-xs transition flex items-center space-x-1 flex-shrink-0 cursor-pointer">
|
||||
<i data-lucide="search" class="w-3 h-3"></i>
|
||||
<span>Zoek vrij blok</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="boardFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-[11px] text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
|
||||
<i data-lucide="check-circle" class="w-3.5 h-3.5 text-emerald-500 flex-shrink-0"></i>
|
||||
<span id="boardFoundSlotText"></span>
|
||||
</div>
|
||||
|
||||
<div id="scheduleEndDateRow" class="hidden">
|
||||
<label class="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">Einddatum herhaling</label>
|
||||
<input type="date" id="cardModalEndDate" value="<?= date('Y-m-d', strtotime('+7 days')) ?>" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-800 dark:text-slate-200 focus:outline-none">
|
||||
@@ -321,7 +415,7 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
</div>
|
||||
|
||||
<!-- MODAL 2: Bord Instellingen, Kolommen & Leden Autorisatie (Beheerder) -->
|
||||
<div id="manageBoardModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div id="manageBoardModal" onclick="if(event.target === this) closeManageBoardModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="fociz-card p-6 w-full max-w-xl shadow-2xl space-y-5 my-8">
|
||||
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||||
@@ -383,7 +477,7 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
</div>
|
||||
|
||||
<!-- MODAL 3: Nieuw Bord Aanmaken (Beheerder) -->
|
||||
<div id="createBoardModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||
<div id="createBoardModal" onclick="if(event.target === this) closeCreateBoardModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 w-full max-w-md shadow-2xl space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white">Nieuw Bord Toevoegen</h3>
|
||||
@@ -405,7 +499,39 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
<button onclick="submitCreateBoard()" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition">Bord Aanmaken</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Floating Rechtermuisknop Contextmenu voor Bord Kaarten -->
|
||||
<div id="boardCardContextMenu" class="hidden fixed z-[999] bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl py-1.5 w-64 text-xs font-medium text-slate-700 dark:text-slate-200 select-none animate-in fade-in zoom-in-95 duration-100">
|
||||
<div class="px-3.5 py-1.5 border-b border-slate-100 dark:border-slate-800/80 mb-1">
|
||||
<span id="boardCtxTitle" class="text-[11px] font-bold text-slate-900 dark:text-white truncate block"></span>
|
||||
<span id="boardCtxMeta" class="text-[10px] text-slate-400 truncate block"></span>
|
||||
</div>
|
||||
|
||||
<button onclick="triggerBoardCtxEdit()" 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>Taak bewerken</span>
|
||||
</button>
|
||||
|
||||
<button onclick="triggerBoardCtxQuickSlot()" 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="zap" class="w-4 h-4 text-amber-500"></i>
|
||||
<span>⚡ Zoek direct vrij blok</span>
|
||||
</button>
|
||||
|
||||
<button onclick="triggerBoardCtxQuickMove(-1)" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 text-slate-500"></i>
|
||||
<span>Verplaats naar vorige kolom</span>
|
||||
</button>
|
||||
|
||||
<button onclick="triggerBoardCtxQuickMove(1)" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="arrow-right" class="w-4 h-4 text-slate-500"></i>
|
||||
<span>Verplaats naar volgende kolom</span>
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
|
||||
|
||||
<button onclick="triggerBoardCtxDelete()" 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>Taak verwijderen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -438,6 +564,10 @@ const LANE_ACCENT_COLORS = {
|
||||
|
||||
// 1. Initialisatie
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('board_id')) {
|
||||
currentBoardId = parseInt(urlParams.get('board_id'), 10);
|
||||
}
|
||||
await loadBoardsList();
|
||||
});
|
||||
|
||||
@@ -484,9 +614,11 @@ function onBoardSelectChange(bId) {
|
||||
}
|
||||
|
||||
// 2. Data van specifiek bord inladen
|
||||
async function loadBoardData(boardId) {
|
||||
async function loadBoardData(boardId, showLoader = true) {
|
||||
currentBoardId = boardId;
|
||||
document.getElementById('linkToTimeline')?.setAttribute('href', `/timeline.php?board_id=${boardId}`);
|
||||
const container = document.getElementById('boardLanesContainer');
|
||||
if (showLoader) {
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center w-full py-20 text-slate-400 text-sm">
|
||||
<i data-lucide="loader-2" class="w-5 h-5 animate-spin mr-2"></i>
|
||||
@@ -494,6 +626,7 @@ async function loadBoardData(boardId) {
|
||||
</div>
|
||||
`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/board.php?action=get_board_data&board_id=${boardId}`);
|
||||
@@ -502,7 +635,14 @@ async function loadBoardData(boardId) {
|
||||
currentBoardData = data;
|
||||
|
||||
// Titels en beheerknop
|
||||
document.getElementById('boardDescriptionText').innerText = data.board.description || 'Geen toelichting';
|
||||
const descEl = document.getElementById('boardDescriptionText');
|
||||
if (data.board.description && data.board.description.trim() !== '') {
|
||||
descEl.innerText = data.board.description;
|
||||
descEl.classList.remove('hidden');
|
||||
} else {
|
||||
descEl.innerText = '';
|
||||
descEl.classList.add('hidden');
|
||||
}
|
||||
const btnManage = document.getElementById('btnManageBoard');
|
||||
const roleBadge = document.getElementById('boardRoleBadge');
|
||||
|
||||
@@ -542,9 +682,13 @@ function onCardDragStart(e, cardId) {
|
||||
function onCardDragEnd(e) {
|
||||
if (draggedCardId) {
|
||||
const el = document.getElementById(`card-${draggedCardId}`);
|
||||
if (el) el.classList.remove('opacity-40');
|
||||
if (el) el.classList.remove('opacity-40', 'pointer-events-none');
|
||||
draggedCardId = null;
|
||||
}
|
||||
// Verwijder eventuele achtergebleven opacity op alle kaarten
|
||||
document.querySelectorAll('[id^="card-"].opacity-40').forEach(el => {
|
||||
el.classList.remove('opacity-40', 'pointer-events-none');
|
||||
});
|
||||
setTimeout(() => {
|
||||
isDraggingCard = false;
|
||||
}, 150);
|
||||
@@ -574,6 +718,23 @@ async function onLaneDrop(e, targetLaneId) {
|
||||
|
||||
const rawId = draggedCardId || e.dataTransfer.getData('text/plain');
|
||||
const cardId = parseInt(rawId, 10);
|
||||
|
||||
// Direct re-activeren van het gesleepte element
|
||||
const activeEl = cardId ? document.getElementById(`card-${cardId}`) : null;
|
||||
if (activeEl) {
|
||||
activeEl.classList.remove('opacity-40', 'pointer-events-none');
|
||||
// Optimistic DOM append als dropZone en element bestaan
|
||||
const emptyNotice = dropZone.querySelector('.pointer-events-none');
|
||||
if (emptyNotice && emptyNotice.innerText.includes('Geen taken')) {
|
||||
emptyNotice.remove();
|
||||
}
|
||||
dropZone.appendChild(activeEl);
|
||||
}
|
||||
draggedCardId = null;
|
||||
setTimeout(() => {
|
||||
isDraggingCard = false;
|
||||
}, 150);
|
||||
|
||||
if (!cardId) return;
|
||||
|
||||
try {
|
||||
@@ -586,7 +747,7 @@ async function onLaneDrop(e, targetLaneId) {
|
||||
target_position: 999
|
||||
})
|
||||
});
|
||||
await loadBoardData(currentBoardId);
|
||||
await loadBoardData(currentBoardId, false);
|
||||
} catch (err) {
|
||||
console.error('Fout bij verplaatsen taak:', err);
|
||||
}
|
||||
@@ -629,9 +790,9 @@ function renderCards() {
|
||||
|
||||
const dotColorClass = LANE_ACCENT_COLORS[lane.color] || 'bg-slate-500';
|
||||
|
||||
// Lane kolom HTML (past zich automatisch aan de schermbreedte aan)
|
||||
// Lane kolom HTML: flex-1 en min-w-[160px] zodat 5+ lanes in standaardmodus binnen 1280px passen zonder scrollbalk
|
||||
const laneEl = document.createElement('div');
|
||||
laneEl.className = 'flex-1 min-w-[260px] rounded-2xl bg-slate-100/70 dark:bg-[#0c0f17]/70 border border-slate-200/80 dark:border-slate-800/80 p-3.5 flex flex-col shadow-xs max-h-[82vh] transition-all duration-200';
|
||||
laneEl.className = 'flex-1 min-w-0 sm:min-w-[160px] rounded-2xl bg-slate-100/70 dark:bg-[#0c0f17]/70 border border-slate-200/80 dark:border-slate-800/80 p-3 flex flex-col shadow-xs max-h-[calc(100vh-310px)] min-h-[440px] transition-all duration-200';
|
||||
laneEl.setAttribute('data-lane-id', lane.id);
|
||||
|
||||
let cardsHtml = '';
|
||||
@@ -652,11 +813,12 @@ function renderCards() {
|
||||
let assigneeHtml = '<span class="text-[10px] text-slate-400 italic">Niet toegewezen</span>';
|
||||
if (card.assignee_name) {
|
||||
const initial = card.assignee_name.charAt(0).toUpperCase();
|
||||
const avatarMarkup = card.assignee_avatar
|
||||
? `<img src="${escapeHtml(card.assignee_avatar)}" class="w-5 h-5 rounded-full object-cover flex-shrink-0 bg-white dark:bg-slate-900 border border-indigo-200 dark:border-indigo-800/60" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');"><div class="hidden w-5 h-5 rounded-full bg-gradient-to-tr from-indigo-600 to-sky-500 text-white font-bold text-[9px] flex items-center justify-center flex-shrink-0">${initial}</div>`
|
||||
: `<div class="w-5 h-5 rounded-full bg-gradient-to-tr from-indigo-600 to-sky-500 text-white font-bold text-[9px] flex items-center justify-center flex-shrink-0">${initial}</div>`;
|
||||
assigneeHtml = `
|
||||
<div class="flex items-center space-x-1.5" title="Toegewezen aan: ${escapeHtml(card.assignee_name)}">
|
||||
<div class="w-5 h-5 rounded-full bg-gradient-to-tr from-indigo-600 to-sky-500 text-white font-bold text-[9px] flex items-center justify-center">
|
||||
${initial}
|
||||
</div>
|
||||
${avatarMarkup}
|
||||
<span class="text-[11px] font-medium text-slate-700 dark:text-slate-300 truncate max-w-[110px]">
|
||||
${escapeHtml(card.assignee_name)}
|
||||
</span>
|
||||
@@ -689,16 +851,17 @@ function renderCards() {
|
||||
ondragstart="onCardDragStart(event, ${card.id})"
|
||||
ondragend="onCardDragEnd(event)"
|
||||
onclick="onCardClick(event, ${card.id})"
|
||||
class="group/card relative p-3.5 rounded-xl border ${colorClass} shadow-2xs hover:shadow-md hover:-translate-y-0.5 transition cursor-grab active:cursor-grabbing mb-2.5"
|
||||
oncontextmenu="openBoardCardContextMenu(event, ${card.id}); return false;"
|
||||
class="group/card relative p-3 rounded-xl border ${colorClass} shadow-2xs hover:shadow-md hover:-translate-y-0.5 transition cursor-grab active:cursor-grabbing mb-2.5 postit-note"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 mb-1.5">
|
||||
<h4 class="text-xs font-bold leading-snug line-clamp-2">
|
||||
<div class="flex items-start justify-between gap-1.5 mb-1.5">
|
||||
<h4 class="text-xs font-bold leading-snug line-clamp-2 break-words">
|
||||
${escapeHtml(card.title)}
|
||||
</h4>
|
||||
${priorityBadge}
|
||||
<div class="flex-shrink-0">${priorityBadge}</div>
|
||||
</div>
|
||||
|
||||
${card.description ? `<p class="text-[11px] opacity-80 line-clamp-2 leading-relaxed mb-2.5">${escapeHtml(card.description)}</p>` : ''}
|
||||
${card.description ? `<p class="text-[11px] opacity-80 line-clamp-2 leading-relaxed mb-2.5 break-words">${escapeHtml(card.description)}</p>` : ''}
|
||||
|
||||
<div class="flex items-center justify-between mt-auto">
|
||||
${assigneeHtml}
|
||||
@@ -719,14 +882,14 @@ function renderCards() {
|
||||
|
||||
laneEl.innerHTML = `
|
||||
<!-- Lane Header -->
|
||||
<div class="flex items-center justify-between pb-3 mb-3 border-b border-slate-200/80 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full ${dotColorClass}"></span>
|
||||
<h3 class="text-xs font-bold text-slate-800 dark:text-slate-200 uppercase tracking-wider">
|
||||
<div class="flex items-center justify-between pb-2.5 mb-2.5 border-b border-slate-200/80 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-2 min-w-0">
|
||||
<span class="w-2.5 h-2.5 rounded-full ${dotColorClass} flex-shrink-0"></span>
|
||||
<h3 class="text-xs font-bold text-slate-800 dark:text-slate-200 uppercase tracking-wider truncate">
|
||||
${escapeHtml(lane.title)}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700 shadow-2xs">
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700 shadow-2xs flex-shrink-0 ml-1">
|
||||
${cards.length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1004,6 +1167,131 @@ async function quickMoveCard(cardId, direction) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rechtermuisknop Context Menu Logica voor Bord
|
||||
let activeBoardCtxCardId = null;
|
||||
|
||||
function openBoardCardContextMenu(e, cardId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
activeBoardCtxCardId = cardId;
|
||||
const menu = document.getElementById('boardCardContextMenu');
|
||||
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 closeBoardCardContextMenu() {
|
||||
const menu = document.getElementById('boardCardContextMenu');
|
||||
if (menu) menu.classList.add('hidden');
|
||||
activeBoardCtxCardId = null;
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#boardCardContextMenu')) {
|
||||
closeBoardCardContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
function triggerBoardCtxEdit() {
|
||||
if (!activeBoardCtxCardId) return;
|
||||
const cid = activeBoardCtxCardId;
|
||||
closeBoardCardContextMenu();
|
||||
openCardModal(cid);
|
||||
}
|
||||
|
||||
async function triggerBoardCtxQuickSlot() {
|
||||
if (!activeBoardCtxCardId) return;
|
||||
const cid = activeBoardCtxCardId;
|
||||
closeBoardCardContextMenu();
|
||||
openCardModal(cid);
|
||||
setTimeout(() => {
|
||||
findFreeSlotForBoardAssignee();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function triggerBoardCtxQuickMove(dir) {
|
||||
if (!activeBoardCtxCardId) return;
|
||||
const cid = activeBoardCtxCardId;
|
||||
closeBoardCardContextMenu();
|
||||
quickMoveCard(cid, dir);
|
||||
}
|
||||
|
||||
async function triggerBoardCtxDelete() {
|
||||
if (!activeBoardCtxCardId) return;
|
||||
const cid = activeBoardCtxCardId;
|
||||
closeBoardCardContextMenu();
|
||||
if (!confirm('Weet je zeker dat je deze taak wilt verwijderen?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/board.php?action=delete_card', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ card_id: cid })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await loadBoardData(currentBoardId);
|
||||
} else {
|
||||
alert(data.error || 'Kon taak niet verwijderen');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Netwerkfout bij verwijderen taak');
|
||||
}
|
||||
}
|
||||
|
||||
// Zoek vrij agendablok voor de in de modal geselecteerde assignee
|
||||
async function findFreeSlotForBoardAssignee() {
|
||||
const sel = document.getElementById('cardModalAssignee');
|
||||
const targetUserId = sel.value ? parseInt(sel.value, 10) : currentUserId;
|
||||
const schedCheckbox = document.getElementById('cardModalScheduleEnabled');
|
||||
if (!schedCheckbox.checked) {
|
||||
schedCheckbox.checked = true;
|
||||
toggleScheduleFields();
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnBoardFindSlot');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader-2" class="w-3 h-3 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: 60,
|
||||
target_user_id: targetUserId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.slot) {
|
||||
const slot = data.slot;
|
||||
document.getElementById('cardModalStartDate').value = slot.date;
|
||||
document.getElementById('cardModalEndDate').value = slot.date;
|
||||
document.getElementById('cardModalStartTime').value = slot.start;
|
||||
document.getElementById('cardModalEndTime').value = slot.end;
|
||||
|
||||
const notice = document.getElementById('boardFoundSlotNotice');
|
||||
const noticeText = document.getElementById('boardFoundSlotText');
|
||||
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 voor deze collega.');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Netwerkfout bij zoeken van vrij agendablok: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Beheer Modal: Lanes & Leden Autorisatie
|
||||
function openManageBoardModal() {
|
||||
if (!currentBoardData) return;
|
||||
@@ -1200,28 +1488,39 @@ function escapeHtml(str) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 10. Bord Maximaliseren & Verkleinen (Soepele breedte-overgang)
|
||||
// 10. Kolommen Maximaliseren & Verkleinen (Soepele breedte-overgang)
|
||||
// De site-header blijft ALTIJD staan.
|
||||
// De bovenbalk (Hoofdbord, filters, actieknoppen) behoudt altijd de vaste standaard breedte.
|
||||
// ALLEEN de aparte lanes wisselen tussen standaard gecentreerde breedte en de volledige schermbreedte.
|
||||
let isBoardMaximized = localStorage.getItem('fociz_board_maximized') === 'true';
|
||||
|
||||
function applyMaximizeState(isMax) {
|
||||
const wrapper = document.getElementById('boardMainWrapper');
|
||||
const lanesWrapper = document.getElementById('lanesSectionWrapper');
|
||||
const mainContainer = document.getElementById('focizMainContainer');
|
||||
const icon = document.getElementById('maximizeIcon');
|
||||
const text = document.getElementById('maximizeText');
|
||||
const btn = document.getElementById('btnToggleMaximize');
|
||||
|
||||
if (!wrapper || !btn) return;
|
||||
if (!lanesWrapper || !btn) return;
|
||||
|
||||
// Zorg dat header altijd zichtbaar blijft
|
||||
document.querySelector('header')?.classList.remove('hidden');
|
||||
if (mainContainer) {
|
||||
mainContainer.style.maxWidth = '';
|
||||
mainContainer.style.paddingLeft = '';
|
||||
mainContainer.style.paddingRight = '';
|
||||
}
|
||||
|
||||
if (isMax) {
|
||||
wrapper.classList.add('is-maximized');
|
||||
if (text) text.innerText = 'Verkleinen';
|
||||
btn.setAttribute('title', 'Bord verkleinen naar standaard breedte');
|
||||
lanesWrapper.classList.remove('max-w-7xl', 'mx-auto');
|
||||
lanesWrapper.classList.add('is-maximized', 'max-w-none', 'w-full', 'px-1', 'sm:px-3');
|
||||
btn.setAttribute('title', 'Kolommen verkleinen naar standaard breedte (Escape)');
|
||||
if (icon) {
|
||||
icon.setAttribute('data-lucide', 'minimize-2');
|
||||
}
|
||||
} else {
|
||||
wrapper.classList.remove('is-maximized');
|
||||
if (text) text.innerText = 'Maximaliseren';
|
||||
btn.setAttribute('title', 'Bord maximaliseren naar volledige schermbreedte');
|
||||
lanesWrapper.classList.remove('is-maximized', 'max-w-none', 'px-1', 'sm:px-3');
|
||||
lanesWrapper.classList.add('max-w-7xl', 'mx-auto', 'w-full');
|
||||
btn.setAttribute('title', 'Kolommen maximaliseren naar volledige vensterbreedte');
|
||||
if (icon) {
|
||||
icon.setAttribute('data-lucide', 'maximize-2');
|
||||
}
|
||||
@@ -1238,11 +1537,25 @@ function toggleBoardMaximize() {
|
||||
// Pas bewaarde breedte-status direct toe bij het laden
|
||||
applyMaximizeState(isBoardMaximized);
|
||||
|
||||
// Sneltoets Escape om gemaximaliseerd bord te verkleinen (mits geen modal openstaat)
|
||||
// Sneltoets Escape om geopende modals te sluiten of gemaximaliseerd bord te verkleinen
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && isBoardMaximized) {
|
||||
const anyModalOpen = document.querySelector('#cardModal:not(.hidden), #manageBoardModal:not(.hidden), #createBoardModal:not(.hidden)');
|
||||
if (!anyModalOpen) {
|
||||
if (e.key === 'Escape') {
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
if (cardModal && !cardModal.classList.contains('hidden')) {
|
||||
closeCardModal();
|
||||
return;
|
||||
}
|
||||
const manageModal = document.getElementById('manageBoardModal');
|
||||
if (manageModal && !manageModal.classList.contains('hidden')) {
|
||||
closeManageBoardModal();
|
||||
return;
|
||||
}
|
||||
const createModal = document.getElementById('createBoardModal');
|
||||
if (createModal && !createModal.classList.contains('hidden')) {
|
||||
closeCreateBoardModal();
|
||||
return;
|
||||
}
|
||||
if (isBoardMaximized) {
|
||||
toggleBoardMaximize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
@@ -88,9 +96,11 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($sessions as $sess): ?>
|
||||
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="group fociz-card-interactive p-5 flex flex-col justify-between hover:border-indigo-400 dark:hover:border-indigo-500/60">
|
||||
<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-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']) ?>
|
||||
@@ -110,29 +120,46 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?= htmlspecialchars($sess['description']) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<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-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-bold flex-shrink-0 ml-2">
|
||||
<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-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 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">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Nieuwe Brainstormsessie</span>
|
||||
</h3>
|
||||
<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>
|
||||
@@ -141,31 +168,78 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<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-semibold text-slate-700 dark:text-slate-300 mb-1">Onderwerp / Titel</label>
|
||||
<input type="text" name="title" required placeholder="Bijv. Q4 Automatisering & Klantreis" 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.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">
|
||||
<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-semibold text-slate-700 dark:text-slate-300 mb-1">Thema / Categorie</label>
|
||||
<select name="theme" 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">
|
||||
<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-semibold text-slate-700 dark:text-slate-300 mb-1">Doel / Beschrijving</label>
|
||||
<input type="text" name="description" placeholder="Doel van deze sessie..." 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>
|
||||
|
||||
<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">
|
||||
Sessie Aanmaken & Canvas Openen
|
||||
<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-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
|
||||
@@ -175,4 +249,110 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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,28 +0,0 @@
|
||||
# =========================================================================
|
||||
# FOCIZ OMGEVINGSCONFIGURATIE VOORBEELD (TEMPLATE)
|
||||
# =========================================================================
|
||||
# Alle actieve database- en applicatie-instellingen staan centraal in:
|
||||
# config/config.php
|
||||
#
|
||||
# Indien gewenst kan een lokaal .env bestand in de hoofdmap worden geplaatst
|
||||
# om specifieke variabelen lokaal te overschrijven.
|
||||
|
||||
DB_HOST="127.0.0.1"
|
||||
DB_PORT="3306"
|
||||
DB_NAME="fociz"
|
||||
DB_USER="fociz_user"
|
||||
DB_PASS=""
|
||||
|
||||
APP_ENV="live"
|
||||
APP_URL="https://www.fociz.nl"
|
||||
APP_SECRET=""
|
||||
|
||||
MICROSOFT_CLIENT_ID=""
|
||||
MICROSOFT_CLIENT_SECRET=""
|
||||
MICROSOFT_TENANT_ID=""
|
||||
|
||||
JIRA_DOMAIN=""
|
||||
JIRA_EMAIL=""
|
||||
JIRA_API_TOKEN=""
|
||||
|
||||
|
||||
@@ -17,13 +17,18 @@ if (!headers_sent()) {
|
||||
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. Optionele .env inlezing uit de hoofdmap (uitsluitend .env, GEEN .env.example fallback)
|
||||
// 1. .env inlezing (controleert eerst server root .env en dan config/.env)
|
||||
$envVars = [];
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$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) {
|
||||
@@ -43,6 +48,8 @@ if (file_exists($envFile)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // Eerste gevonden .env bestand is leidend
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,30 +81,52 @@ if (!function_exists('fociz_env')) {
|
||||
define('APP_NAME', 'Fociz');
|
||||
|
||||
// Automatische omgevingsdetectie:
|
||||
// 1. Expliciete .env variabele 'APP_ENV'
|
||||
// 2. Automatisch 'development' indien hostname 'dev.' of 'localhost' bevat
|
||||
// 3. Standaard 'live'
|
||||
$detectedHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
// 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')
|
||||
);
|
||||
$defaultEnv = $isDevHost ? 'development' : 'live';
|
||||
define('APP_ENV', fociz_env('APP_ENV', $defaultEnv));
|
||||
$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)
|
||||
// =========================================================================
|
||||
// Leest eerst eventuele .env variabelen (die per server uniek en gitignored zijn).
|
||||
// Indien niet ingesteld via .env, wordt automatisch de juiste database gekozen op basis van APP_ENV.
|
||||
$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', $isDevEnv ? 'focizdev' : 'fociz'));
|
||||
define('DB_USER', fociz_env('DB_USER', $isDevEnv ? 'focizdev_user' : 'focizlive_user'));
|
||||
define('DB_PASS', fociz_env('DB_PASS', $isDevEnv ? 'F0c1z_Dev_Secure_2026!' : '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
|
||||
@@ -128,6 +157,18 @@ define('APP_URL', fociz_detect_app_url());
|
||||
// Tijdzone
|
||||
date_default_timezone_set('Europe/Amsterdam');
|
||||
|
||||
// =========================================================================
|
||||
// 3b. SMTP MAIL SERVER CONFIGURATIE
|
||||
// =========================================================================
|
||||
define('SMTP_HOST', fociz_env('SMTP_HOST', 'mail.fociz.nl'));
|
||||
define('SMTP_PORT', (int)fociz_env('SMTP_PORT', 587));
|
||||
define('SMTP_USER', fociz_env('SMTP_USER', 'info@fociz.nl'));
|
||||
define('SMTP_PASS', fociz_env('SMTP_PASS', '6swpQSBg5uWFmjar7F6a'));
|
||||
define('SMTP_SECURE', fociz_env('SMTP_SECURE', 'tls')); // 'tls' (STARTTLS port 587) of 'ssl' (port 465)
|
||||
define('SMTP_FROM_EMAIL', fociz_env('SMTP_FROM_EMAIL', '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
|
||||
// =========================================================================
|
||||
@@ -141,10 +182,7 @@ if (APP_ENV === 'live') {
|
||||
$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') ||
|
||||
(isset($_GET['debug']) && $_GET['debug'] === 'fociz')
|
||||
);
|
||||
$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>
|
||||
|
||||
@@ -32,6 +32,7 @@ function get_db_connection(): PDO {
|
||||
");
|
||||
}
|
||||
|
||||
$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,
|
||||
@@ -41,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',
|
||||
@@ -53,6 +57,8 @@ function get_db_connection(): PDO {
|
||||
// 2. CHECK: Bestaat de database al?
|
||||
try {
|
||||
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
|
||||
// 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;
|
||||
|
||||
@@ -70,6 +76,7 @@ function get_db_connection(): PDO {
|
||||
));
|
||||
|
||||
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
|
||||
$pdo->exec("SET time_zone = '{$tzOffset}'");
|
||||
ensure_tables_exist($pdo);
|
||||
return $pdo;
|
||||
|
||||
@@ -119,8 +126,14 @@ function ensure_tables_exist(PDO $pdo): void {
|
||||
if ($tablesChecked) return;
|
||||
$tablesChecked = true;
|
||||
|
||||
try {
|
||||
$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;
|
||||
}
|
||||
|
||||
try {
|
||||
if (file_exists($schemaFile)) {
|
||||
$sql = file_get_contents($schemaFile);
|
||||
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||
@@ -146,8 +159,39 @@ function ensure_tables_exist(PDO $pdo): void {
|
||||
"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 {
|
||||
@@ -157,6 +201,66 @@ function ensure_tables_exist(PDO $pdo): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -185,8 +289,26 @@ function ensure_tables_exist(PDO $pdo): void {
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien tabellen nog in opbouw zijn
|
||||
// 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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
|
||||
@@ -207,3 +230,108 @@ CREATE TABLE IF NOT EXISTS `board_card_schedules` (
|
||||
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>
|
||||
|
||||
@@ -8,27 +8,420 @@ require_once __DIR__ . '/http.php';
|
||||
require_once __DIR__ . '/settings.php';
|
||||
|
||||
/**
|
||||
* Voert een gestructureerde chat completion uit met de geconfigureerde AI provider
|
||||
* 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 [['role' => 'system'|'user'|'assistant', 'content' => string]]
|
||||
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int]
|
||||
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string]
|
||||
* @param array $messages Referentie naar de messages array die verzonden wordt
|
||||
* @return array Aantal en type geactiveerde firewall filters
|
||||
*/
|
||||
function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
$aiSettings = get_ai_settings();
|
||||
if (empty($aiSettings['ai_api_key'])) {
|
||||
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 AI API-sleutel geconfigureerd. Stel deze in via Systeembeheer (admin.php).',
|
||||
'error' => 'Geen Google Gemini API-sleutel ingevuld of geconfigureerd.',
|
||||
];
|
||||
}
|
||||
|
||||
$apiKey = $aiSettings['ai_api_key'];
|
||||
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gpt-4o-mini';
|
||||
// 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,
|
||||
@@ -47,12 +440,21 @@ function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 35,
|
||||
'timeout' => 20,
|
||||
]);
|
||||
|
||||
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
|
||||
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "AI aanroep mislukt (HTTP {$response['status']})");
|
||||
error_log("fociz_ai_chat fout: {$errMsg}");
|
||||
$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,
|
||||
@@ -66,11 +468,230 @@ function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
$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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -81,7 +702,7 @@ function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
* - 'Oplossingen'
|
||||
* - 'Quick Wins'
|
||||
*/
|
||||
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards): array {
|
||||
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards, ?int $userId = null): array {
|
||||
if (empty($cards)) {
|
||||
return ['success' => true, 'categorized' => []];
|
||||
}
|
||||
@@ -109,7 +730,7 @@ function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $c
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true]);
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren'];
|
||||
@@ -121,28 +742,89 @@ function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $c
|
||||
/**
|
||||
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
|
||||
*/
|
||||
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4): array {
|
||||
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"
|
||||
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een gele post-it staan).\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."],
|
||||
], ['json_mode' => true]);
|
||||
['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'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
|
||||
$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 = ''): array {
|
||||
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"
|
||||
@@ -154,39 +836,72 @@ function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"],
|
||||
], ['json_mode' => true]);
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren'];
|
||||
// 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 bestaande afspraken
|
||||
* 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 $events, array $workdaySettings): array {
|
||||
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: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 bestaande afspraken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\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 = "Bestaande afspraken:\n" . json_encode($events, JSON_UNESCAPED_UNICODE) . "\n\n"
|
||||
. "In te plannen taken:\n" . json_encode($tasks, JSON_UNESCAPED_UNICODE);
|
||||
$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]);
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen'];
|
||||
@@ -195,3 +910,74 @@ function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySett
|
||||
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,13 +14,9 @@
|
||||
|
||||
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']);
|
||||
?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -7,6 +7,11 @@ 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">
|
||||
@@ -15,6 +20,9 @@ $currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUse
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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() {
|
||||
@@ -61,9 +69,12 @@ $currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUse
|
||||
<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';
|
||||
}
|
||||
};
|
||||
@@ -143,9 +154,9 @@ require_once __DIR__ . '/env_badge.php';
|
||||
<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 dev/live 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">
|
||||
<!-- 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>
|
||||
@@ -158,13 +169,19 @@ require_once __DIR__ . '/env_badge.php';
|
||||
<!-- 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 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="calendar" class="w-4 h-4"></i>
|
||||
<span>Planning</span>
|
||||
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
|
||||
<span>Fociz</span>
|
||||
</a>
|
||||
|
||||
<!-- Dropdown: Projecten (Bord, Brainstorm, Roadmap) -->
|
||||
<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', 'brainstorm.php', 'brainstorm_canvas.php', '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' ?>">
|
||||
@@ -185,7 +202,18 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 2. Brainstorm -->
|
||||
<!-- 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>
|
||||
@@ -210,9 +238,9 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/profile.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'profile.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="user" class="w-4 h-4"></i>
|
||||
<span>Mijn Profiel</span>
|
||||
<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 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' ?>">
|
||||
@@ -227,14 +255,85 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 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; ?>
|
||||
|
||||
<!-- Right side: Clean User Profile & Logout -->
|
||||
<div class="flex items-center space-x-2 sm:space-x-3">
|
||||
<div class="flex items-center space-x-2 sm:space-x-3 shrink-0">
|
||||
|
||||
<!-- 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" 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">
|
||||
<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>
|
||||
<?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">
|
||||
@@ -248,7 +347,7 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</a>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
@@ -257,8 +356,15 @@ require_once __DIR__ . '/env_badge.php';
|
||||
<!-- Mobile Navigation -->
|
||||
<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="calendar" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Planning</span>
|
||||
<i data-lucide="layout-dashboard" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Fociz</span>
|
||||
</a>
|
||||
<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>
|
||||
@@ -272,9 +378,9 @@ require_once __DIR__ . '/env_badge.php';
|
||||
<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-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark: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>
|
||||
<?php if ($currentUser['role'] === 'ADMIN'): ?>
|
||||
<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' ?>">
|
||||
@@ -460,4 +566,5 @@ function focizQuickToggleTheme() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
|
||||
<?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,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,36 +29,54 @@ function ensure_system_settings_table(?PDO $db = null): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt een enkele instelling op uit MariaDB
|
||||
* Haalt alle systeeminstellingen in 1 geoptimaliseerde query op en bewaart deze in het static runtime geheugen
|
||||
*/
|
||||
function get_system_setting(string $key, $default = null) {
|
||||
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->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?");
|
||||
$stmt->execute([$key]);
|
||||
$val = $stmt->fetchColumn();
|
||||
|
||||
if ($val === false || $val === null) {
|
||||
return $default;
|
||||
$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;
|
||||
}
|
||||
|
||||
// Decodeer JSON indien van toepassing
|
||||
$trimmed = trim((string)$val);
|
||||
if (str_starts_with($trimmed, '{') || str_starts_with($trimmed, '[')) {
|
||||
$decoded = json_decode($val, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
return $decoded;
|
||||
$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 $val;
|
||||
} catch (Throwable $e) {
|
||||
error_log("get_system_setting error for {$key}: " . $e->getMessage());
|
||||
return $default;
|
||||
}
|
||||
return $settingsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slaat een instelling op in MariaDB
|
||||
* 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 {
|
||||
@@ -72,7 +90,11 @@ function set_system_setting(string $key, $value): bool {
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()
|
||||
");
|
||||
return $stmt->execute([$key, $valToStore]);
|
||||
$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;
|
||||
@@ -83,32 +105,233 @@ function set_system_setting(string $key, $value): bool {
|
||||
* 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: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 v1.5)
|
||||
* Haalt AI configuratie-instellingen op (Release v2.0 - Google Gemini Pro & OpenAI)
|
||||
* Ondersteunt organisatiebrede en optioneel persoonlijke sleutels.
|
||||
*/
|
||||
function get_ai_settings(): array {
|
||||
$envKey = fociz_env('OPENAI_API_KEY', '');
|
||||
$savedKey = get_system_setting('ai_api_key', '');
|
||||
$apiKey = !empty($savedKey) ? $savedKey : $envKey;
|
||||
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', !empty($apiKey)),
|
||||
'ai_provider' => get_system_setting('ai_provider', 'openai'),
|
||||
'ai_enabled' => (bool)get_system_setting('ai_enabled', $hasKey),
|
||||
'ai_provider' => (string)$provider,
|
||||
'ai_api_key' => $apiKey,
|
||||
'ai_model' => get_system_setting('ai_model', 'gpt-4o-mini'),
|
||||
'has_key' => !empty($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 '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,10 @@ if (!empty($_GET['success'])) {
|
||||
|
||||
// POST Verwerking VÓÓR headers zodat redirects en mutaties soepel verlopen
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$message = "Beveiligingsfout: CSRF-token ongeldig of verlopen. Ververs de pagina en probeer opnieuw.";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
// 1. Outlook koppelen via OAuth
|
||||
@@ -79,7 +83,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
|
||||
|
||||
if (($jiraToken === '' || $jiraToken === '••••••••••••••••') && !empty($existingSettings['jira_api_token']) && $existingSettings['jira_api_token'] !== '••••••••••••••••') {
|
||||
$jiraToken = $existingSettings['jira_api_token'];
|
||||
$jiraToken = fociz_decrypt($existingSettings['jira_api_token']);
|
||||
}
|
||||
|
||||
$accountName = $existingSettings['account_name'] ?? '';
|
||||
@@ -104,7 +108,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$newSettings = [
|
||||
'jira_domain' => $jiraDomain,
|
||||
'jira_email' => $jiraEmail,
|
||||
'jira_api_token' => $jiraToken,
|
||||
'jira_api_token' => fociz_encrypt($jiraToken),
|
||||
'account_name' => $accountName,
|
||||
'account_id' => $accountId,
|
||||
];
|
||||
@@ -128,6 +132,48 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4b. Persoonlijke Google Gemini / AI instellingen opslaan
|
||||
elseif ($action === 'save_personal_ai') {
|
||||
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
|
||||
$aiProvider = trim($_POST['ai_provider'] ?? 'gemini');
|
||||
$aiModel = trim($_POST['ai_model'] ?? 'gemini-2.0-flash');
|
||||
$apiKeyIn = trim($_POST['ai_api_key'] ?? '');
|
||||
|
||||
// Haal eventueel bestaande integratie op
|
||||
$stmtEx = $db->prepare("SELECT id, access_token, settings FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') LIMIT 1");
|
||||
$stmtEx->execute([$userId]);
|
||||
$existing = $stmtEx->fetch();
|
||||
|
||||
$tokenToSave = $existing['access_token'] ?? null;
|
||||
if ($apiKeyIn !== '' && $apiKeyIn !== '••••••••••••••••') {
|
||||
$tokenToSave = fociz_encrypt($apiKeyIn);
|
||||
}
|
||||
|
||||
$settingsJson = json_encode([
|
||||
'provider' => $aiProvider,
|
||||
'model' => $aiModel,
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
$stmtUp = $db->prepare("UPDATE integrations SET provider = 'GEMINI', is_enabled = ?, access_token = ?, settings = ?, last_sync_at = NOW() WHERE id = ?");
|
||||
$stmtUp->execute([$aiEnabled, $tokenToSave, $settingsJson, $existing['id']]);
|
||||
} else {
|
||||
$stmtIn = $db->prepare("INSERT INTO integrations (user_id, provider, is_enabled, access_token, settings, last_sync_at) VALUES (?, 'GEMINI', ?, ?, ?, NOW())");
|
||||
$stmtIn->execute([$userId, $aiEnabled, $tokenToSave, $settingsJson]);
|
||||
}
|
||||
|
||||
$message = "Uw persoonlijke AI instellingen zijn succesvol opgeslagen.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
|
||||
// 4c. Persoonlijke AI sleutel ontkoppelen / verwijderen
|
||||
elseif ($action === 'disconnect_personal_ai') {
|
||||
$stmtDel = $db->prepare("DELETE FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI')");
|
||||
$stmtDel->execute([$userId]);
|
||||
$message = "Uw persoonlijke AI sleutel is ontkoppeld. Fociz gebruikt nu de centrale instellingen van de organisatie.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
|
||||
// 5. Notificatievoorkeuren opslaan
|
||||
elseif ($action === 'save_notifications') {
|
||||
$settings = [
|
||||
@@ -171,17 +217,84 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!password_verify($currentPassword, $storedHash)) {
|
||||
$message = "Uw huidige wachtwoord is niet correct ingevoerd.";
|
||||
$messageType = 'error';
|
||||
} elseif (strlen($newPassword) < 8) {
|
||||
$message = "Het nieuwe wachtwoord moet minimaal 8 tekens lang zijn.";
|
||||
} elseif (($pwErr = fociz_validate_password($newPassword)) !== null) {
|
||||
$message = $pwErr;
|
||||
$messageType = 'error';
|
||||
} elseif ($newPassword !== $confirmPassword) {
|
||||
$message = "Het nieuwe wachtwoord en de bevestiging komen niet overeen.";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
$stmtUp = $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
|
||||
$stmtUp->execute([$newHash, $userId]);
|
||||
$message = "Uw wachtwoord is succesvol gewijzigd.";
|
||||
|
||||
// SOC 2 CC6.1 / ISO 27001 A.9.4.2: Trek overige sessies in bij wachtwoordwijziging
|
||||
fociz_revoke_other_sessions($userId, session_id());
|
||||
|
||||
fociz_log_audit($userId, 'PASSWORD_CHANGED', 'user', $userId, ['method' => 'profile'], 'SUCCESS', $user['email']);
|
||||
$message = "Uw wachtwoord is succesvol gewijzigd. Alle overige sessies op andere apparaten zijn uit veiligheidsoverwegingen beëindigd.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
// 7b. Alle overige sessies op afstand beëindigen (SOC 2 / ISO 27001)
|
||||
elseif ($action === 'revoke_other_sessions') {
|
||||
$count = fociz_revoke_other_sessions($userId, session_id());
|
||||
$message = "Succesvol {$count} andere actieve sessie(s) op afstand beëindigd.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
|
||||
// 8. Standaard avatar kiezen uit bibliotheek
|
||||
elseif ($action === 'select_preset_avatar') {
|
||||
$preset = trim($_POST['avatar_preset'] ?? '');
|
||||
if (preg_match('/^\/assets\/avatars\/avatar-(0[1-9]|1[0-9]|2[0-4])\.svg$/', $preset)) {
|
||||
$stmtUp = $db->prepare("UPDATE users SET avatar_url = ? WHERE id = ?");
|
||||
$stmtUp->execute([$preset, $userId]);
|
||||
$_SESSION['user_avatar_url'] = $preset;
|
||||
$user['avatar_url'] = $preset;
|
||||
$message = "Uw avatar is succesvol bijgewerkt!";
|
||||
$messageType = 'success';
|
||||
} else {
|
||||
$message = "Ongeldige avatar geselecteerd.";
|
||||
$messageType = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Eigen profielfoto uploaden
|
||||
elseif ($action === 'upload_custom_avatar') {
|
||||
if (!isset($_FILES['avatar_file']) || $_FILES['avatar_file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$message = "Selecteer een geldige afbeelding om te uploaden.";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$file = $_FILES['avatar_file'];
|
||||
$maxSize = 4 * 1024 * 1024; // 4MB
|
||||
if ($file['size'] > $maxSize) {
|
||||
$message = "De afbeelding is te groot (maximaal 4MB toegestaan).";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$processed = fociz_process_avatar_upload($file, $userId);
|
||||
if (!$processed['success']) {
|
||||
$message = $processed['error'] ?? "Fout bij verwerken van de afbeelding.";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$avatarUri = $processed['data_uri'];
|
||||
$stmtUp = $db->prepare("UPDATE users SET avatar_url = ? WHERE id = ?");
|
||||
$stmtUp->execute([$avatarUri, $userId]);
|
||||
$_SESSION['user_avatar_url'] = $avatarUri;
|
||||
$user['avatar_url'] = $avatarUri;
|
||||
$message = "Uw profielfoto is succesvol geüpload en permanent bewaard!";
|
||||
$messageType = 'success';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Avatar verwijderen
|
||||
elseif ($action === 'remove_avatar') {
|
||||
$stmtUp = $db->prepare("UPDATE users SET avatar_url = NULL WHERE id = ?");
|
||||
$stmtUp->execute([$userId]);
|
||||
unset($_SESSION['user_avatar_url']);
|
||||
$user['avatar_url'] = null;
|
||||
$message = "Uw avatar is teruggezet naar de standaard weergave met uw initialen.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
}
|
||||
@@ -191,6 +304,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$stmtUser = $db->prepare("SELECT * FROM users WHERE id = ?");
|
||||
$stmtUser->execute([$userId]);
|
||||
$userProfile = $stmtUser->fetch();
|
||||
if ($userProfile) {
|
||||
$userProfile['avatar_url'] = fociz_get_valid_avatar_url($userProfile['avatar_url'] ?? null, $userId);
|
||||
}
|
||||
|
||||
// Outlook status ophalen
|
||||
$stmtOutlook = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||
@@ -214,9 +330,20 @@ $appConfig = microsoft_get_app_config();
|
||||
// Notificatie-instellingen
|
||||
$notifSettings = get_user_notification_settings($userId);
|
||||
|
||||
// AI Co-Pilot & Google Gemini instellingen voor gebruiker
|
||||
$userAiSettings = get_ai_settings($userId);
|
||||
$stmtAiInt = $db->prepare("SELECT id, access_token, is_enabled, settings FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') LIMIT 1");
|
||||
$stmtAiInt->execute([$userId]);
|
||||
$userAiIntRow = $stmtAiInt->fetch();
|
||||
$hasPersonalAiKey = !empty($userAiIntRow['access_token']);
|
||||
$userAiRowSettings = json_decode($userAiIntRow['settings'] ?? '{}', true) ?: [];
|
||||
|
||||
// Thema ophalen
|
||||
$userTheme = get_user_theme($userId);
|
||||
|
||||
// Actieve apparaten & sessies ophalen (SOC 2 / ISO 27001)
|
||||
$activeSessions = fociz_get_user_active_sessions($userId, session_id());
|
||||
|
||||
$pageTitle = 'Mijn Profiel & Koppelingen — Fociz';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
@@ -228,10 +355,17 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<div class="relative z-10 flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||
<div class="flex items-center space-x-4 sm:space-x-5">
|
||||
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 shadow-sm flex-shrink-0">
|
||||
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 shadow-sm flex-shrink-0 overflow-hidden">
|
||||
<?php if (!empty($userProfile['avatar_url'])): ?>
|
||||
<img src="<?= htmlspecialchars($userProfile['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-2xl object-cover bg-white dark:bg-[#0c0f17]" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
|
||||
<div class="hidden w-full h-full rounded-2xl bg-white dark:bg-[#0c0f17] flex items-center justify-center font-bold text-2xl sm:text-3xl text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($userProfile['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="w-full h-full rounded-2xl bg-white dark:bg-[#0c0f17] flex items-center justify-center font-bold text-2xl sm:text-3xl text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($userProfile['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
@@ -419,6 +553,138 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sectie 0b: Profielfoto & Avatar Beheer -->
|
||||
<div class="fociz-card p-6 sm:p-7 space-y-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-gradient-to-tr from-indigo-500 to-pink-500 p-0.5 shadow-xs flex-shrink-0 flex items-center justify-center text-white">
|
||||
<i data-lucide="smile" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Profielfoto & Avatar</h2>
|
||||
<span class="px-2.5 py-0.5 rounded-full <?= !empty($userProfile['avatar_url']) ? 'bg-teal-50 dark:bg-teal-500/10 border border-teal-200 dark:border-teal-500/20 text-teal-700 dark:text-teal-400' : 'bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400' ?> text-[10px] font-semibold">
|
||||
<?= !empty($userProfile['avatar_url']) ? 'Aangepaste Avatar Actief' : 'Standaard Initiaal-Badge' ?>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">Kies een mini-avatar uit de 24 stijlvolle karakters of upload een eigen afbeelding.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($userProfile['avatar_url'])): ?>
|
||||
<form method="POST" action="/profile.php" class="self-start sm:self-auto">
|
||||
<input type="hidden" name="action" value="remove_avatar">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" onclick="return confirm('Avatar herstellen naar standaard initiaal-badge?')" class="inline-flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800/50 hover:bg-rose-100 dark:hover:bg-rose-900/50 transition cursor-pointer">
|
||||
<i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i>
|
||||
<span>Standaard Herstellen</span>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Avatar Selector Grid & Upload -->
|
||||
<div class="mt-6 grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||
<!-- Linker kolom: 24 mini karakters in grid (8 cols) -->
|
||||
<div class="lg:col-span-8 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-bold text-slate-800 dark:text-slate-200 flex items-center space-x-1.5">
|
||||
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||||
<span>Kies een Karakter (24 Stijlen)</span>
|
||||
</h3>
|
||||
|
||||
<!-- Categorie Filters -->
|
||||
<div class="flex items-center space-x-1 bg-slate-100 dark:bg-[#0c0f17] p-1 rounded-xl border border-slate-200 dark:border-slate-800 text-[11px]" id="avatarFilterBar">
|
||||
<button type="button" onclick="filterAvatars('all')" class="avatar-filter-btn active px-2.5 py-1 rounded-lg font-medium transition cursor-pointer" data-cat="all">Alles</button>
|
||||
<button type="button" onclick="filterAvatars('pro')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="pro">Pro</button>
|
||||
<button type="button" onclick="filterAvatars('creative')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="creative">Creatief</button>
|
||||
<button type="button" onclick="filterAvatars('tech')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="tech">Tech</button>
|
||||
<button type="button" onclick="filterAvatars('heroes')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="heroes">Heroes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2.5 p-3 rounded-2xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800" id="avatarGrid">
|
||||
<?php
|
||||
$avatarPresets = [
|
||||
['file' => 'avatar-01.svg', 'cat' => 'pro', 'name' => 'Alex (Business)'],
|
||||
['file' => 'avatar-02.svg', 'cat' => 'pro', 'name' => 'Sophie (Corporate)'],
|
||||
['file' => 'avatar-03.svg', 'cat' => 'pro', 'name' => 'Liam (Consultant)'],
|
||||
['file' => 'avatar-04.svg', 'cat' => 'pro', 'name' => 'Emma (Manager)'],
|
||||
['file' => 'avatar-05.svg', 'cat' => 'pro', 'name' => 'Daan (Director)'],
|
||||
['file' => 'avatar-06.svg', 'cat' => 'pro', 'name' => 'Noa (Executive)'],
|
||||
['file' => 'avatar-07.svg', 'cat' => 'creative', 'name' => 'Mila (Designer)'],
|
||||
['file' => 'avatar-08.svg', 'cat' => 'creative', 'name' => 'Lucas (Creator)'],
|
||||
['file' => 'avatar-09.svg', 'cat' => 'creative', 'name' => 'Zara (Artist)'],
|
||||
['file' => 'avatar-10.svg', 'cat' => 'creative', 'name' => 'Finn (Illustrator)'],
|
||||
['file' => 'avatar-11.svg', 'cat' => 'creative', 'name' => 'Lotte (Writer)'],
|
||||
['file' => 'avatar-12.svg', 'cat' => 'creative', 'name' => 'Sem (Architect)'],
|
||||
['file' => 'avatar-13.svg', 'cat' => 'tech', 'name' => 'Bram (Developer)'],
|
||||
['file' => 'avatar-14.svg', 'cat' => 'tech', 'name' => 'Julia (Engineer)'],
|
||||
['file' => 'avatar-15.svg', 'cat' => 'tech', 'name' => 'Jesse (DevOps)'],
|
||||
['file' => 'avatar-16.svg', 'cat' => 'tech', 'name' => 'Tess (Data Scientist)'],
|
||||
['file' => 'avatar-17.svg', 'cat' => 'tech', 'name' => 'Milan (Cyber Sec)'],
|
||||
['file' => 'avatar-18.svg', 'cat' => 'tech', 'name' => 'Liv (AI Engineer)'],
|
||||
['file' => 'avatar-19.svg', 'cat' => 'heroes', 'name' => 'Max (Ninja)'],
|
||||
['file' => 'avatar-20.svg', 'cat' => 'heroes', 'name' => 'Sara (Astronaut)'],
|
||||
['file' => 'avatar-21.svg', 'cat' => 'heroes', 'name' => 'Robin (Pilot)'],
|
||||
['file' => 'avatar-22.svg', 'cat' => 'heroes', 'name' => 'Eva (Explorer)'],
|
||||
['file' => 'avatar-23.svg', 'cat' => 'heroes', 'name' => 'Leo (Cosmic)'],
|
||||
['file' => 'avatar-24.svg', 'cat' => 'heroes', 'name' => 'Nova (Cyberpunk)'],
|
||||
];
|
||||
|
||||
foreach ($avatarPresets as $av):
|
||||
$avatarPath = "/assets/avatars/{$av['file']}";
|
||||
$isSelected = ($userProfile['avatar_url'] === $avatarPath);
|
||||
?>
|
||||
<form method="POST" action="/profile.php" class="relative group avatar-item" data-category="<?= $av['cat'] ?>">
|
||||
<input type="hidden" name="action" value="select_preset_avatar">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<input type="hidden" name="avatar_preset" value="<?= $avatarPath ?>">
|
||||
<button type="submit" class="w-full aspect-square rounded-2xl p-1 transition-all duration-200 transform hover:scale-110 active:scale-95 hover:shadow-lg hover:shadow-indigo-500/25 focus:outline-none cursor-pointer <?= $isSelected ? 'ring-2 ring-indigo-600 dark:ring-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 shadow-sm' : 'hover:bg-white dark:hover:bg-slate-800 border border-transparent' ?>" title="<?= htmlspecialchars($av['name']) ?>">
|
||||
<img src="<?= $avatarPath ?>" alt="<?= htmlspecialchars($av['name']) ?>" class="w-full h-full rounded-xl object-contain pointer-events-none drop-shadow-xs">
|
||||
<?php if ($isSelected): ?>
|
||||
<span class="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-indigo-600 text-white flex items-center justify-center shadow-xs border-2 border-white dark:border-[#0c0f17]">
|
||||
<i data-lucide="check" class="w-3 h-3 stroke-[3]"></i>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechter kolom: Eigen Afbeelding Uploaden (4 cols) -->
|
||||
<div class="lg:col-span-4 space-y-3">
|
||||
<h3 class="text-xs font-bold text-slate-800 dark:text-slate-200 flex items-center space-x-1.5">
|
||||
<i data-lucide="upload-cloud" class="w-3.5 h-3.5 text-sky-500"></i>
|
||||
<span>Eigen Foto Uploaden</span>
|
||||
</h3>
|
||||
|
||||
<form method="POST" action="/profile.php" enctype="multipart/form-data" class="p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 space-y-4">
|
||||
<input type="hidden" name="action" value="upload_custom_avatar">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="text-center p-5 border-2 border-dashed border-slate-300 dark:border-slate-700 rounded-xl hover:border-indigo-500 dark:hover:border-indigo-400 transition bg-white dark:bg-[#111622]">
|
||||
<div class="w-10 h-10 mx-auto mb-2 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
|
||||
<i data-lucide="image" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<label for="customAvatarFile" class="block text-xs font-semibold text-indigo-600 dark:text-indigo-400 hover:underline cursor-pointer mb-1">
|
||||
Klik om bestand te kiezen
|
||||
</label>
|
||||
<p class="text-[10px] text-slate-500 dark:text-slate-400">JPG, PNG, WebP of GIF (max. 4MB)</p>
|
||||
<input type="file" id="customAvatarFile" name="avatar_file" accept="image/png, image/jpeg, image/webp, image/gif" required class="hidden" onchange="previewUploadAvatar(this)">
|
||||
<div id="uploadFileName" class="mt-2 text-[11px] font-mono text-slate-700 dark:text-slate-300 truncate hidden"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="uploadAvatarBtn" 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 flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<i data-lucide="upload" class="w-4 h-4"></i>
|
||||
<span>Foto Uploaden & Opslaan</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sectie 1: Mijn Microsoft 365 Outlook Agenda -->
|
||||
<div class="fociz-card p-6 sm:p-7">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
|
||||
@@ -428,7 +694,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Mijn Microsoft 365 Agenda</h2>
|
||||
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Mijn Microsoft 365 (Agenda & E-mail)</h2>
|
||||
<?php if ($isOutlookConnected): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-500/15 border border-teal-200 dark:border-teal-500/30 text-teal-700 dark:text-teal-400 text-[10px] font-semibold flex items-center space-x-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse"></span>
|
||||
@@ -440,17 +706,18 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">Synchroniseer uw agenda-afspraken automatisch met uw dagplanning</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">Synchroniseer uw agenda-afspraken en zakelijke Outlook e-mails met Fociz</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($isOutlookConnected): ?>
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="/auth/oauth_microsoft.php" class="px-3 py-1.5 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 border border-slate-200 dark:border-slate-700 text-xs font-medium transition" title="Opnieuw autoriseren bij Microsoft">
|
||||
Opnieuw Verbinden
|
||||
<a href="/auth/oauth_microsoft.php?reconsent=1" class="px-3 py-1.5 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 border border-slate-200 dark:border-slate-700 text-xs font-medium transition" title="Machtigingen bijwerken bij Microsoft (inclusief Mail.ReadWrite)">
|
||||
Machtigingen Bijwerken
|
||||
</a>
|
||||
<form method="POST" onsubmit="return confirm('Weet u zeker dat u uw Microsoft Outlook agenda wilt ontkoppelen?');">
|
||||
<input type="hidden" name="action" value="disconnect_outlook">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" class="px-3 py-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-500/10 dark:hover:bg-rose-500/20 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20 text-xs font-medium transition cursor-pointer">
|
||||
Ontkoppelen
|
||||
</button>
|
||||
@@ -480,6 +747,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="mt-4 p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<input type="hidden" name="action" value="save_outlook_settings">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<div class="space-y-1">
|
||||
<label for="sync_interval_seconds" class="text-xs font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
|
||||
<i data-lucide="refresh-cw" class="w-3.5 h-3.5 text-sky-500"></i>
|
||||
@@ -529,6 +797,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<div>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="connect_outlook">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" class="inline-flex items-center space-x-2 px-6 py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs transition shadow-xs cursor-pointer">
|
||||
<i data-lucide="log-in" class="w-4 h-4"></i>
|
||||
<span>Koppel Mijn Outlook Agenda</span>
|
||||
@@ -579,6 +848,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?php if ($isJiraConnected): ?>
|
||||
<form method="POST" onsubmit="return confirm('Weet u zeker dat u Jira wilt ontkoppelen?');">
|
||||
<input type="hidden" name="action" value="disconnect_jira">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" class="px-3 py-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-500/10 dark:hover:bg-rose-500/20 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20 text-xs font-medium transition cursor-pointer">
|
||||
Jira Ontkoppelen
|
||||
</button>
|
||||
@@ -588,6 +858,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="action" value="save_jira">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -618,7 +889,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input type="password" id="jiraApiTokenInput" name="jira_api_token" value="<?= !empty($jiraSettings['jira_api_token']) ? '••••••••••••••••' : '' ?>" placeholder="Plak hier uw Atlassian API token..." class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl pl-3.5 pr-10 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<button type="button" onclick="toggleJiraToken()" class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
|
||||
<button type="button" onclick="toggleJiraToken()" id="toggleJiraTokenBtn" title="Toon / verberg token" class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
|
||||
<i data-lucide="eye" class="w-4 h-4" id="eyeJiraIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -641,6 +912,138 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sectie 2b: Mijn AI Co-Pilot & Google Gemini Pro -->
|
||||
<div class="fociz-card p-6 sm:p-7">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs">
|
||||
<i data-lucide="sparkles" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">AI Co-Pilot & Google Gemini Pro</h2>
|
||||
<?php if ($hasPersonalAiKey): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-500/15 border border-indigo-200 dark:border-indigo-500/30 text-indigo-700 dark:text-indigo-400 text-[10px] font-semibold">
|
||||
Eigen Sleutel
|
||||
</span>
|
||||
<?php elseif (!empty($userAiSettings['has_key'])): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-500/15 border border-teal-200 dark:border-teal-500/30 text-teal-700 dark:text-teal-400 text-[10px] font-semibold">
|
||||
Organisatiesleutel Actief
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-amber-700 dark:text-amber-400 text-[10px] font-semibold">
|
||||
Geen Sleutel
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Koppel uw eigen Google Gemini Pro account of gebruik de centrale organisatieconfiguratie</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/60 text-xs font-bold flex items-center space-x-1">
|
||||
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i>
|
||||
<span>Zero-Data Leakage Actief</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Shield Banner -->
|
||||
<div class="mt-6 p-3.5 rounded-2xl bg-emerald-50/70 dark:bg-emerald-950/20 border border-emerald-200/70 dark:border-emerald-800/40 flex items-start space-x-3">
|
||||
<div class="w-7 h-7 rounded-xl bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="shield-check" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<strong class="text-emerald-900 dark:text-emerald-200 font-bold block">100% Zero-Data Leakage Garantie</strong>
|
||||
<p class="text-emerald-800/80 dark:text-emerald-300/80 text-[11px] mt-0.5">
|
||||
Uw gevoelige gegevens uit <strong>Jira tickets</strong>, <strong>Outlook e-mails</strong> en <strong>Microsoft Teams</strong> worden strikt afgeschermd en <strong>NOOIT</strong> naar Google Gemini of OpenAI verstuurd.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/profile.php" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="action" value="save_personal_ai">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="p-3.5 rounded-2xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800">
|
||||
<label class="flex items-center space-x-2.5 text-xs text-slate-900 dark:text-white cursor-pointer select-none">
|
||||
<input type="checkbox" name="ai_enabled" value="1" <?= (!isset($userAiIntRow['is_enabled']) || !empty($userAiIntRow['is_enabled'])) ? 'checked' : '' ?> class="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700">
|
||||
<span class="font-semibold">AI Co-Pilot assistentie inschakelen voor mijn account</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
|
||||
<select name="ai_model" id="profile_ai_model" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<optgroup label="Google Gemini (Aanbevolen)">
|
||||
<option value="gemini-2.0-flash" <?= ($userAiSettings['ai_model'] === 'gemini-2.0-flash' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Aanbevolen, Razendsnel)</option>
|
||||
<option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
|
||||
<option value="gemini-1.5-pro" <?= $userAiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro</option>
|
||||
</optgroup>
|
||||
<optgroup label="OpenAI">
|
||||
<option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
|
||||
<option value="gpt-4o" <?= $userAiSettings['ai_model'] === 'gpt-4o' ? 'selected' : '' ?>>OpenAI GPT-4o</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<input type="hidden" name="ai_provider" id="profile_ai_provider" value="<?= strpos($userAiSettings['ai_model'], 'gpt') !== false ? 'openai' : 'gemini' ?>">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Persoonlijke Google Gemini API Sleutel
|
||||
<span class="text-[11px] font-normal text-slate-500 dark:text-slate-400">(Optioneel)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
name="ai_api_key"
|
||||
id="profile_ai_api_key"
|
||||
value="<?= $hasPersonalAiKey ? '••••••••••••••••' : '' ?>"
|
||||
placeholder="<?= $userAiSettings['has_key'] ? 'Gebruikt organisatie-sleutel (leeg laten)' : 'AIzaSy... (Google AI Studio)' ?>"
|
||||
class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl pl-3.5 pr-9 py-2.5 text-xs text-slate-900 dark:text-white font-mono placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<button type="button" onclick="togglePasswordVisibility('profile_ai_api_key', this)" class="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="block text-[11px] text-slate-500 dark:text-slate-400 mt-1">
|
||||
Gratis sleutel aanmaken? <a href="https://aistudio.google.com/app/apikey" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">Google AI Studio →</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Test Resultaat Alert in Profiel -->
|
||||
<div id="profileAiTestResult" class="hidden p-3 rounded-xl text-xs"></div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button type="button" onclick="testPersonalAiConnection()" id="profileAiTestBtn" class="px-4 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-200 text-xs font-semibold transition cursor-pointer flex items-center space-x-2">
|
||||
<i data-lucide="activity" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||||
<span id="profileAiTestBtnText">Test Verbinding</span>
|
||||
</button>
|
||||
|
||||
<?php if ($hasPersonalAiKey): ?>
|
||||
<button type="submit" form="disconnectPersonalAiForm" class="px-3 py-2 rounded-xl text-xs text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 transition cursor-pointer font-medium">
|
||||
Persoonlijke Sleutel Verwijderen
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition shadow-xs cursor-pointer">
|
||||
AI Instellingen Opslaan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if ($hasPersonalAiKey): ?>
|
||||
<form method="POST" id="disconnectPersonalAiForm" class="hidden">
|
||||
<input type="hidden" name="action" value="disconnect_personal_ai">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Sectie 3: Mijn Meldingen & Agenda Push Notificaties -->
|
||||
<div class="fociz-card p-6 sm:p-7">
|
||||
<div class="flex items-center space-x-3.5 pb-6 border-b border-slate-100 dark:border-slate-800/80">
|
||||
@@ -689,6 +1092,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="space-y-4">
|
||||
<input type="hidden" name="action" value="save_notifications">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="flex items-center justify-between p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800">
|
||||
<div>
|
||||
@@ -798,6 +1202,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="space-y-4">
|
||||
<input type="hidden" name="action" value="update_profile">
|
||||
<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.5">
|
||||
@@ -831,6 +1236,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="space-y-3">
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
<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">
|
||||
@@ -841,16 +1247,17 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Nieuw Wachtwoord (min. 8 tekens)
|
||||
Nieuw Wachtwoord (min. 12 tekens)
|
||||
</label>
|
||||
<input type="password" name="new_password" required minlength="8" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<input type="password" name="new_password" required minlength="12" placeholder="Minimaal 12 tekens" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<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>
|
||||
<input type="password" name="confirm_password" required minlength="8" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<input type="password" name="confirm_password" required minlength="12" placeholder="Herhaal nieuw wachtwoord" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
@@ -861,6 +1268,72 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actieve Apparaten & Sessies Card (SOC 2 CC6.1 / ISO 27001 A.9.4.2) -->
|
||||
<div class="fociz-card p-6 sm:p-7 space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs flex-shrink-0">
|
||||
<i data-lucide="laptop" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Actieve Apparaten & Sessies</h3>
|
||||
<span class="px-2 py-0.5 rounded-full bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/60 text-emerald-700 dark:text-emerald-300 font-semibold text-[10px]">
|
||||
<?= count($activeSessions) ?> <?= count($activeSessions) === 1 ? 'actieve sessie' : 'actieve sessies' ?>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Centraal overzicht van alle actieve inlogapparaten conform ISO 27001 en SOC 2 richtlijnen (maximaal 20 actieve sessies).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (count($activeSessions) > 1): ?>
|
||||
<form method="POST" action="/profile.php" onsubmit="return confirm('Weet u zeker dat u alle andere actieve sessies op afstand wilt beëindigen? U blijft op dit apparaat ingelogd.')">
|
||||
<input type="hidden" name="action" value="revoke_other_sessions">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" class="inline-flex items-center space-x-2 px-3.5 py-2 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-950/30 dark:hover:bg-rose-900/40 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/60 text-xs font-semibold transition cursor-pointer">
|
||||
<i data-lucide="log-out" class="w-3.5 h-3.5"></i>
|
||||
<span>Overal Uitloggen (Overige Sessies)</span>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<?php foreach ($activeSessions as $s): ?>
|
||||
<div class="p-3.5 rounded-2xl <?= $s['is_current'] ? 'bg-indigo-50/40 dark:bg-indigo-950/20 border-indigo-200/80 dark:border-indigo-800/50' : 'bg-slate-50/70 dark:bg-[#0c0f17]/60 border-slate-200 dark:border-slate-800' ?> border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-9 h-9 rounded-xl <?= $s['is_current'] ? 'bg-indigo-600 text-white shadow-xs' : 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400' ?> flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="<?= stripos($s['device_name'] ?? '', 'Phone') !== false || stripos($s['device_name'] ?? '', 'iPhone') !== false || stripos($s['device_name'] ?? '', 'Android') !== false ? 'smartphone' : 'monitor' ?>" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($s['device_name'] ?: 'Onbekend apparaat') ?></span>
|
||||
<?php if ($s['is_current']): ?>
|
||||
<span class="px-2 py-0.5 rounded-md bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800/60 text-[10px] font-bold">
|
||||
Dit apparaat (Huidig)
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 flex items-center space-x-2 mt-0.5">
|
||||
<span class="font-mono"><?= htmlspecialchars($s['ip_address']) ?></span>
|
||||
<span>•</span>
|
||||
<span>Laatst actief: <?= date('d-m-Y H:i', strtotime($s['last_activity_at'])) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!$s['is_current']): ?>
|
||||
<button type="button" onclick="revokeSessionById(<?= (int)$s['id'] ?>)" class="self-end sm:self-center px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-rose-50 dark:bg-slate-800 dark:hover:bg-rose-950/30 text-slate-600 hover:text-rose-600 dark:text-slate-400 dark:hover:text-rose-400 text-xs font-semibold transition border border-slate-200 dark:border-slate-700 hover:border-rose-200 dark:hover:border-rose-800 cursor-pointer">
|
||||
Sessie Beëindigen
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -899,20 +1372,82 @@ function updateProfileThemeUI(theme) {
|
||||
|
||||
function toggleJiraToken() {
|
||||
const input = document.getElementById('jiraApiTokenInput');
|
||||
const icon = document.getElementById('eyeJiraIcon');
|
||||
if (!input || !icon) return;
|
||||
const btn = document.getElementById('toggleJiraTokenBtn');
|
||||
if (!input) return;
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.setAttribute('data-lucide', 'eye-off');
|
||||
if (btn) btn.innerHTML = '<i data-lucide="eye-off" class="w-4 h-4"></i>';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.setAttribute('data-lucide', 'eye');
|
||||
if (btn) btn.innerHTML = '<i data-lucide="eye" class="w-4 h-4"></i>';
|
||||
}
|
||||
if (window.lucide && typeof lucide.createIcons === 'function') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
function previewUploadAvatar(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
const nameEl = document.getElementById('uploadFileName');
|
||||
if (nameEl) {
|
||||
nameEl.textContent = '📄 ' + file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
||||
nameEl.classList.remove('hidden');
|
||||
}
|
||||
// Live thumbnail weergave
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
let prevImg = document.getElementById('uploadAvatarLivePreview');
|
||||
if (!prevImg && nameEl && nameEl.parentNode) {
|
||||
prevImg = document.createElement('img');
|
||||
prevImg.id = 'uploadAvatarLivePreview';
|
||||
prevImg.className = 'w-16 h-16 rounded-full mx-auto my-2 object-cover border-2 border-indigo-500 shadow-sm';
|
||||
nameEl.parentNode.insertBefore(prevImg, nameEl);
|
||||
}
|
||||
if (prevImg) {
|
||||
prevImg.src = e.target.result;
|
||||
prevImg.classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function filterAvatarCategory(category) {
|
||||
const items = document.querySelectorAll('.avatar-item');
|
||||
items.forEach(el => {
|
||||
const cat = el.getAttribute('data-category');
|
||||
if (category === 'all' || cat === category) {
|
||||
el.classList.remove('hidden');
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
const btns = document.querySelectorAll('.av-cat-btn');
|
||||
btns.forEach(btn => {
|
||||
btn.className = 'av-cat-btn px-2.5 py-1 rounded-lg text-[11px] font-semibold transition cursor-pointer bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300';
|
||||
});
|
||||
const activeBtn = document.getElementById('avCatBtn-' + category);
|
||||
if (activeBtn) {
|
||||
activeBtn.className = 'av-cat-btn px-2.5 py-1 rounded-lg text-[11px] font-bold transition cursor-pointer bg-indigo-600 text-white shadow-xs';
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
// Sync banner status met browser permissie & activeer actuele thema kaart
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Model en provider sync
|
||||
const profileModelSelect = document.getElementById('profile_ai_model');
|
||||
const profileProviderInput = document.getElementById('profile_ai_provider');
|
||||
if (profileModelSelect && profileProviderInput) {
|
||||
profileModelSelect.addEventListener('change', function() {
|
||||
if (this.value.indexOf('gemini') !== -1) {
|
||||
profileProviderInput.value = 'gemini';
|
||||
} else {
|
||||
profileProviderInput.value = 'openai';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialiseer geselecteerde thema-kaart
|
||||
const activeTheme = localStorage.getItem('fociz_theme') || '<?= $userTheme ?>' || 'dark';
|
||||
updateProfileThemeUI(activeTheme);
|
||||
@@ -937,6 +1472,79 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (deniedEl) deniedEl.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function testPersonalAiConnection() {
|
||||
const btn = document.getElementById('profileAiTestBtn');
|
||||
const btnText = document.getElementById('profileAiTestBtnText');
|
||||
const resultDiv = document.getElementById('profileAiTestResult');
|
||||
const apiKey = document.getElementById('profile_ai_api_key').value.trim();
|
||||
const model = document.getElementById('profile_ai_model').value;
|
||||
const provider = document.getElementById('profile_ai_provider').value;
|
||||
|
||||
resultDiv.classList.add('hidden');
|
||||
resultDiv.className = 'hidden p-3 rounded-xl text-xs';
|
||||
btn.disabled = true;
|
||||
btnText.innerText = 'Testen...';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
provider: provider,
|
||||
model: model,
|
||||
};
|
||||
// Stuur alleen de ingevulde sleutel mee als het geen placeholder bolletjes zijn
|
||||
if (apiKey && apiKey !== '••••••••••••••••') {
|
||||
payload.api_key = apiKey;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/ai.php?action=test_connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
resultDiv.classList.remove('hidden');
|
||||
if (data.success) {
|
||||
resultDiv.className = 'p-3 rounded-xl text-xs bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200 flex items-center space-x-2';
|
||||
resultDiv.innerHTML = `<i data-lucide="check-circle" class="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0"></i><span><strong>Verbinding Geslaagd!</strong> ${data.message}</span>`;
|
||||
} else {
|
||||
resultDiv.className = 'p-3 rounded-xl text-xs bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800 text-rose-800 dark:text-rose-200 flex items-center space-x-2';
|
||||
resultDiv.innerHTML = `<i data-lucide="alert-circle" class="w-4 h-4 text-rose-600 dark:text-rose-400 flex-shrink-0"></i><span><strong>Verbinding Mislukt:</strong> ${data.error || 'Onbekende fout'}</span>`;
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (err) {
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.className = 'p-3 rounded-xl text-xs bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800 text-rose-800 dark:text-rose-200 flex items-center space-x-2';
|
||||
resultDiv.innerHTML = `<i data-lucide="alert-circle" class="w-4 h-4 text-rose-600 dark:text-rose-400 flex-shrink-0"></i><span><strong>Netwerkfout:</strong> ${err.message}</span>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btnText.innerText = 'Test Verbinding';
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSessionById(sessionId) {
|
||||
if (!confirm('Weet u zeker dat u deze sessie op afstand wilt beëindigen?')) return;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'revoke_session');
|
||||
formData.append('session_id', sessionId);
|
||||
formData.append('csrf_token', '<?= htmlspecialchars(get_csrf_token()) ?>');
|
||||
|
||||
const res = await fetch('/api/session.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Fout bij beëindigen van sessie.');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Netwerkfout bij beëindigen van sessie: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
@@ -38,6 +38,21 @@ try {
|
||||
}
|
||||
|
||||
$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 max-w-7xl mx-auto">
|
||||
@@ -64,13 +79,21 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
|
||||
<!-- Filters: Quarters & Status -->
|
||||
<div class="fociz-card p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<!-- Quarter Tabs -->
|
||||
<!-- 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-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-lg transition 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' ?>">
|
||||
<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; ?>
|
||||
@@ -108,7 +131,23 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
default => 'border-l-4 border-l-slate-300 dark:border-l-slate-700',
|
||||
};
|
||||
?>
|
||||
<div class="fociz-card-interactive p-5 space-y-3.5 <?= $statusBorder ?>" id="roadmap-item-<?= $item['id'] ?>">
|
||||
<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">
|
||||
@@ -206,7 +245,7 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nieuw Roadmap Item Handmatig Toevoegen -->
|
||||
<div id="newItemModal" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<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">
|
||||
@@ -281,6 +320,237 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
</div>
|
||||
</div>
|
||||
</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) {
|
||||
@@ -334,6 +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,30 +36,61 @@ 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 = [];
|
||||
@@ -91,7 +129,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
Team & Uitnodigingen
|
||||
</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
||||
Fociz is strikt afgesloten: nieuwe collega's kunnen enkel toegang krijgen na het ontvangen van een uitnodigingslink en verplichte 2FA-activatie.
|
||||
Beheer collega's, rollen en uitnodigingslinks voor toegang tot de Fociz omgeving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -130,6 +168,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<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-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres van collega</label>
|
||||
@@ -177,8 +216,24 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?php foreach ($users as $u): ?>
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
|
||||
<td class="p-3">
|
||||
<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-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' ?>">
|
||||
@@ -196,9 +251,14 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</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-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
|
||||
<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-400 dark:text-slate-600 font-medium">Uzelf</span>
|
||||
<?php endif; ?>
|
||||
@@ -240,7 +300,11 @@ require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<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"><?= htmlspecialchars($inv['role']) ?></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-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>
|
||||
@@ -252,9 +316,27 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</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-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<?php if (!$isUsed && !$isExpired): ?>
|
||||
<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; ?>
|
||||
|
||||