Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30be1dfee9 | |||
| fc1a211c73 | |||
| 38701bf06a |
@@ -799,8 +799,8 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?= htmlspecialchars($usr['email']) ?>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold <?= $usr['role'] === 'ADMIN' ? 'bg-purple-100 text-purple-800 dark:bg-purple-950/60 dark:text-purple-300' : 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' ?>">
|
||||
<?= $usr['role'] ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $usr['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' ?>">
|
||||
<?= $usr['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
@@ -1029,51 +1029,93 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<i data-lucide="sparkles" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">AI & Brainstorm Co-Pilot</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">Intelligente assistentie voor post-it categorisatie, ideegeneratie en slimme planning</p>
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">AI & Brainstorm Co-Pilot (Google Gemini Pro)</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">Intelligente assistentie voor post-it ontleding, categorisatie, ideegeneratie en slimme planning</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</span>
|
||||
</span>
|
||||
<span class="px-2.5 py-1 rounded-full <?= $aiSettings['has_key'] ? 'bg-teal-50 dark:bg-teal-950/40 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60' : 'bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800/60' ?> text-xs font-bold">
|
||||
<?= $aiSettings['has_key'] ? 'AI Actief (v1.5)' : 'Release v1.5 Sleutel Vereist' ?>
|
||||
<?= $aiSettings['has_key'] ? 'AI Actief (' . htmlspecialchars($aiSettings['ai_model']) . ')' : 'Sleutel Vereist' ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/admin.php" class="space-y-4">
|
||||
<!-- ZERO-DATA LEAKAGE PRIVACY FIREWALL BANNER -->
|
||||
<div class="p-3.5 rounded-2xl bg-emerald-50/80 dark:bg-emerald-950/20 border border-emerald-200/80 dark:border-emerald-800/40 flex items-start space-x-3">
|
||||
<div class="w-8 h-8 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 space-y-1">
|
||||
<strong class="text-emerald-950 dark:text-emerald-200 font-bold block text-xs">Strikte Zero-Data Leakage Privacy Garantie</strong>
|
||||
<p class="text-emerald-800/90 dark:text-emerald-300/90 text-[11px] leading-relaxed">
|
||||
Er wordt <strong>onder géén enkele voorwaarde data uit Jira tickets, Outlook e-mails of Microsoft Teams</strong> naar de AI verzonden. De ingebouwde privacy firewall onderschept en filtert automatisch ticketnummers, Atlassian-links, mailheaders en communicatietraces vóór verzending.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/admin.php" id="adminAiForm" class="space-y-4">
|
||||
<input type="hidden" name="action" value="save_ai_settings">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="p-3.5 rounded-2xl bg-amber-50/70 dark:bg-amber-950/20 border border-amber-200/70 dark:border-amber-800/40">
|
||||
<label class="flex items-center space-x-2 text-xs text-amber-900 dark:text-amber-200 cursor-pointer select-none">
|
||||
<div class="p-3.5 rounded-2xl bg-indigo-50/50 dark:bg-indigo-950/20 border border-indigo-200/60 dark:border-indigo-800/40">
|
||||
<label class="flex items-center space-x-2.5 text-xs text-indigo-950 dark:text-indigo-200 cursor-pointer select-none">
|
||||
<input type="checkbox" name="ai_enabled" value="1" <?= $aiSettings['ai_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-bold">AI Co-Pilot inschakelen voor alle teamleden</span>
|
||||
<span class="font-bold">AI Co-Pilot inschakelen voor alle gebruikers in de organisatie</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" class="w-full bg-slate-50 dark:bg-[#0c0f17] 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">
|
||||
<option value="gpt-4o-mini" <?= $aiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini (Aanbevolen & Snel)</option>
|
||||
<option value="gpt-4o" <?= $aiSettings['ai_model'] === 'gpt-4o' ? 'selected' : '' ?>>OpenAI GPT-4o (Krachtig & Uitgebreid)</option>
|
||||
<option value="claude-3-5-sonnet-20241022" <?= $aiSettings['ai_model'] === 'claude-3-5-sonnet-20241022' ? 'selected' : '' ?>>Anthropic Claude 3.5 Sonnet</option>
|
||||
<select name="ai_model" id="admin_ai_model" class="w-full bg-slate-50 dark:bg-[#0c0f17] 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">
|
||||
<optgroup label="Google Gemini (Aanbevolen)">
|
||||
<option value="gemini-1.5-pro" <?= ($aiSettings['ai_model'] === 'gemini-1.5-pro' || empty($aiSettings['ai_model']) || $aiSettings['ai_model'] === 'gemini') ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Aanbevolen & Slim)</option>
|
||||
<option value="gemini-2.0-flash" <?= $aiSettings['ai_model'] === 'gemini-2.0-flash' ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Snel & Modern)</option>
|
||||
<option value="gemini-1.5-flash" <?= $aiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash (Lichtgewicht)</option>
|
||||
</optgroup>
|
||||
<optgroup label="OpenAI">
|
||||
<option value="gpt-4o-mini" <?= $aiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
|
||||
<option value="gpt-4o" <?= $aiSettings['ai_model'] === 'gpt-4o' ? 'selected' : '' ?>>OpenAI GPT-4o</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<input type="hidden" name="ai_provider" id="admin_ai_provider" value="<?= strpos($aiSettings['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">OpenAI / Provider API Sleutel</label>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">Google Gemini / AI API Sleutel</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
name="ai_api_key"
|
||||
id="admin_ai_api_key"
|
||||
value="<?= $aiSettings['has_key'] ? '••••••••••••••••' : '' ?>"
|
||||
placeholder="sk-proj-..."
|
||||
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs font-mono placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="AIzaSy... (Google) of sk-... (OpenAI)"
|
||||
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl pl-3 pr-9 py-2 text-slate-900 dark:text-white text-xs font-mono placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<button type="button" onclick="togglePasswordVisibility('admin_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">Haal een gratis of betaalde Gemini API sleutel via <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>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
|
||||
AI Instellingen Opslaan
|
||||
<!-- Live Test Resultaat Alert -->
|
||||
<div id="adminAiTestResult" 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">
|
||||
<button type="button" onclick="testAdminAiConnection()" id="adminAiTestBtn" 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="adminAiTestBtnText">Test Verbinding</span>
|
||||
</button>
|
||||
|
||||
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer flex items-center space-x-2">
|
||||
<i data-lucide="save" class="w-3.5 h-3.5"></i>
|
||||
<span>AI Instellingen Opslaan</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1530,6 +1572,70 @@ function closeJiraFilterModal() {
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const modelSelect = document.getElementById('admin_ai_model');
|
||||
const providerInput = document.getElementById('admin_ai_provider');
|
||||
if (modelSelect && providerInput) {
|
||||
modelSelect.addEventListener('change', function() {
|
||||
if (this.value.indexOf('gemini') !== -1) {
|
||||
providerInput.value = 'gemini';
|
||||
} else {
|
||||
providerInput.value = 'openai';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function testAdminAiConnection() {
|
||||
const btn = document.getElementById('adminAiTestBtn');
|
||||
const btnText = document.getElementById('adminAiTestBtnText');
|
||||
const resultDiv = document.getElementById('adminAiTestResult');
|
||||
const apiKey = document.getElementById('admin_ai_api_key').value.trim();
|
||||
const model = document.getElementById('admin_ai_model').value;
|
||||
const provider = document.getElementById('admin_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 opgetreden'}</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';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeJiraFilterModal();
|
||||
|
||||
+337
-37
@@ -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']]);
|
||||
@@ -87,7 +87,7 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count);
|
||||
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
@@ -132,7 +132,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 +143,283 @@ 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') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
|
||||
$tempProvider = !empty($data['provider']) ? trim($data['provider']) : null;
|
||||
$tempKey = !empty($data['api_key']) ? trim($data['api_key']) : null;
|
||||
$tempModel = !empty($data['model']) ? trim($data['model']) : null;
|
||||
|
||||
// Als er test credentials zijn meegegeven, test direct met die credentials
|
||||
if (!empty($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($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' => 20, 'temperature' => 0.1], $testSettings);
|
||||
} else {
|
||||
$aiRes = fociz_ai_chat_openai($testMessages, ['max_tokens' => 20, '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;
|
||||
}
|
||||
|
||||
// 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 +430,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']]);
|
||||
@@ -206,12 +501,13 @@ try {
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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();
|
||||
@@ -221,15 +517,21 @@ try {
|
||||
? "t.daily_plan_id = " . (int)$planId . ($isToday ? " OR t.daily_plan_id IS NULL" : "")
|
||||
: ($isToday ? "t.daily_plan_id IS NULL" : "1=0");
|
||||
|
||||
$tasks = $db->query("
|
||||
$stmtTasks = $db->prepare("
|
||||
SELECT id, title, priority, time_block_start, duration_minutes
|
||||
FROM tasks
|
||||
WHERE user_id = {$user['id']} AND is_completed = 0 AND ({$taskFilter})
|
||||
ORDER BY priority ASC, time_block_start ASC
|
||||
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
|
||||
")->fetchAll();
|
||||
");
|
||||
$stmtTasks->execute([$user['id']]);
|
||||
$tasks = $stmtTasks->fetchAll();
|
||||
|
||||
// Haal gisteren niet-voltooide taken op
|
||||
// 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]);
|
||||
@@ -239,35 +541,32 @@ try {
|
||||
$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();
|
||||
}
|
||||
|
||||
// Haal Outlook meetings op als beschikbaar
|
||||
$meetings = [];
|
||||
// ZERO-DATA LEAKAGE: Alleen geanonimiseerde bezette tijdblokken (geen subject/afzender)
|
||||
$busyBlocks = [];
|
||||
try {
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
$meetings = 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) {
|
||||
// meetings niet beschikbaar, geen probleem
|
||||
// Geen agenda beschikbaar
|
||||
}
|
||||
|
||||
// Haal urgente Jira tickets op als beschikbaar
|
||||
$jiraIssues = [];
|
||||
try {
|
||||
require_once __DIR__ . '/../includes/integrations/jira.php';
|
||||
$jiraData = jira_get_dashboard_data($user['id'], false);
|
||||
$jiraIssues = array_merge(
|
||||
$jiraData['assigned_issues'] ?? [],
|
||||
$jiraData['team_issues'] ?? []
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
// Jira niet beschikbaar, geen probleem
|
||||
}
|
||||
|
||||
$aiSettings = get_ai_settings();
|
||||
$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;
|
||||
@@ -277,9 +576,9 @@ try {
|
||||
$user['name'] ?? 'Jij',
|
||||
$date,
|
||||
$tasks,
|
||||
$meetings,
|
||||
$jiraIssues,
|
||||
$unfinishedYest
|
||||
$busyBlocks,
|
||||
$unfinishedYest,
|
||||
$user['id']
|
||||
);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
@@ -294,6 +593,7 @@ try {
|
||||
'tip' => $aiRes['tip'],
|
||||
'warnings' => $aiRes['warnings'],
|
||||
'date' => $date,
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ $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,
|
||||
@@ -50,6 +56,7 @@ if ($action === 'logout') {
|
||||
$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());
|
||||
@@ -66,6 +73,51 @@ if ($action === 'logout') {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
@@ -420,7 +423,10 @@
|
||||
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' });
|
||||
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();
|
||||
|
||||
@@ -167,6 +167,14 @@
|
||||
function registerUserActivity() {
|
||||
var now = Date.now();
|
||||
|
||||
// Controleer ALTIJD eerst of de sessie al verlopen was terwijl de gebruiker weg was (bijv. laptop slaapstand of inactiviteit)
|
||||
var elapsedSec = Math.floor((now - lastActivity) / 1000);
|
||||
if (elapsedSec >= idleTimeout) {
|
||||
localStorage.setItem('fociz_session_logged_out', now.toString());
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
return;
|
||||
}
|
||||
|
||||
// Als de waarschuwingsmodal actief is, negeren we vage muisbewegingen;
|
||||
// de gebruiker moet expliciet op 'Blijf Ingelogd' klikken conform beveiligingsnormen.
|
||||
if (isWarningShowing) return;
|
||||
@@ -186,6 +194,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Direct controleren bij terugkeer naar tabblad of ontwaken uit slaapstand
|
||||
function checkIdleOnWakeup() {
|
||||
var now = Date.now();
|
||||
var storedActivity = localStorage.getItem('fociz_session_last_activity');
|
||||
if (storedActivity) {
|
||||
var parsed = parseInt(storedActivity, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
lastActivity = Math.max(lastActivity, parsed);
|
||||
}
|
||||
}
|
||||
var elapsedSec = Math.floor((now - lastActivity) / 1000);
|
||||
if (elapsedSec >= idleTimeout) {
|
||||
localStorage.setItem('fociz_session_logged_out', now.toString());
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+13
-1
@@ -77,15 +77,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
fociz_log_audit($newUserId, 'USER_REGISTERED_VIA_INVITE', 'user', $newUserId, ['email' => $invite['email'], 'role' => $invite['role']], 'SUCCESS', $invite['email']);
|
||||
|
||||
// Start dagsessie (geldig tot 23:59:59)
|
||||
// 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');
|
||||
|
||||
@@ -24,6 +24,9 @@ $infoMessage = null;
|
||||
if (isset($_GET['expired'])) {
|
||||
$infoMessage = "Uw sessie is beëindigd wegens inactiviteit of een dagwissel. Log opnieuw in om verder te gaan.";
|
||||
}
|
||||
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.";
|
||||
}
|
||||
@@ -148,6 +151,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
|
||||
fociz_register_user_ip((int)$user['id'], $clientIp, true);
|
||||
|
||||
// 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'];
|
||||
@@ -163,6 +173,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag)
|
||||
issue_day_session_token((int)$user['id']);
|
||||
|
||||
// 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'], 'SUCCESS', $user['email']);
|
||||
|
||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
|
||||
|
||||
+6
-2
@@ -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
|
||||
}
|
||||
|
||||
+13
-1
@@ -62,15 +62,27 @@ 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);
|
||||
|
||||
+529
-5
@@ -6,7 +6,8 @@
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
require_once __DIR__ . '/includes/settings.php';
|
||||
$db = get_db_connection();
|
||||
$aiSettings = get_ai_settings();
|
||||
$userId = (int)($currentUser['id'] ?? 0);
|
||||
$aiSettings = get_ai_settings($userId);
|
||||
|
||||
$sessionId = (int)($_GET['id'] ?? 0);
|
||||
$stmtSession = $db->prepare("
|
||||
@@ -164,7 +165,15 @@ $categories = [
|
||||
|
||||
<!-- Footer van kaart -->
|
||||
<div class="mt-3 pt-2.5 border-t border-slate-200/60 dark:border-slate-800/60 flex items-center justify-between text-[10px] text-slate-500 dark:text-slate-400">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span><?= htmlspecialchars($card['author_name'] ?? 'Collega') ?></span>
|
||||
<?php if ($aiSettings['ai_enabled'] && $aiSettings['has_key']): ?>
|
||||
<button onclick="openAiBreakdownModal(<?= $card['id'] ?>, <?= htmlspecialchars(json_encode($card['content']), ENT_QUOTES) ?>)" class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-lg bg-violet-100/90 hover:bg-violet-200 dark:bg-violet-950/60 dark:hover:bg-violet-900/60 text-violet-700 dark:text-violet-300 font-bold text-[10px] transition cursor-pointer border border-violet-200/80 dark:border-violet-800/60 shadow-2xs" title="Splits dit idee uit naar behapbare taken via Google Gemini Pro">
|
||||
<i data-lucide="sparkles" class="w-3 h-3 text-violet-500"></i>
|
||||
<span>Werk uit met AI</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Upvote button -->
|
||||
@@ -317,6 +326,175 @@ $categories = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: AI Idee Uitwerken & Exporteren (Release Google Gemini Pro) -->
|
||||
<div id="aiBreakdownModal" onclick="if(event.target === this) closeAiBreakdownModal()" class="hidden fixed inset-0 z-50 bg-slate-950/75 backdrop-blur-sm flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||
<div class="fociz-card p-5 sm:p-6 shadow-2xl max-w-2xl w-full my-8 space-y-4 max-h-[92vh] flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800 flex-shrink-0">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-violet-100 dark:bg-violet-900/40 text-violet-600 dark:text-violet-300 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="sparkles" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm sm:text-base font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<span>Idee Uitwerken naar Taken via AI</span>
|
||||
</h3>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400">Google Gemini Pro ontleedt het idee in behapbare items</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="hidden sm:inline-flex px-2 py-0.5 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-[10px] font-bold items-center space-x-1">
|
||||
<i data-lucide="shield-check" class="w-3 h-3"></i>
|
||||
<span>Zero-Data Leakage</span>
|
||||
</span>
|
||||
<button onclick="closeAiBreakdownModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content area -->
|
||||
<div class="space-y-4 overflow-y-auto pr-1 flex-1">
|
||||
<!-- Geselecteerd Idee Preview -->
|
||||
<div class="p-3 rounded-xl bg-violet-50/70 dark:bg-violet-950/20 border border-violet-200/70 dark:border-violet-800/40">
|
||||
<div class="flex items-center justify-between text-[10px] text-violet-700 dark:text-violet-300 font-bold uppercase tracking-wider mb-1">
|
||||
<span>Oorspronkelijk Brainstorm Idee</span>
|
||||
<span id="breakdownSessionLabel" class="font-normal normal-case text-slate-500 dark:text-slate-400"></span>
|
||||
</div>
|
||||
<p id="breakdownIdeaText" class="text-xs text-slate-800 dark:text-slate-200 italic font-medium leading-relaxed"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State met Spinner -->
|
||||
<div id="breakdownLoading" class="py-12 flex flex-col items-center justify-center space-y-3 text-center">
|
||||
<div class="w-10 h-10 rounded-full border-3 border-violet-500/20 border-t-violet-600 animate-spin"></div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-bold text-slate-900 dark:text-white">Google Gemini Pro aan het werk...</p>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400">Idee wordt ontleed in 3-5 concrete user stories en acceptatiecriteria</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="breakdownError" class="hidden p-4 rounded-xl bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800 text-rose-800 dark:text-rose-200 text-xs">
|
||||
<div class="flex items-center space-x-2 font-bold mb-1">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4 text-rose-600 dark:text-rose-400"></i>
|
||||
<span>Kon taken niet genereren</span>
|
||||
</div>
|
||||
<p id="breakdownErrorMsg" class="text-[11px] text-rose-700 dark:text-rose-300"></p>
|
||||
</div>
|
||||
|
||||
<!-- Resultaat Container -->
|
||||
<div id="breakdownResult" class="hidden space-y-4">
|
||||
<!-- AI Aanpak Samenvatting -->
|
||||
<div id="breakdownSummaryBox" class="p-3 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs flex items-start space-x-2.5">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-violet-500 flex-shrink-0 mt-0.5"></i>
|
||||
<div>
|
||||
<strong class="text-slate-900 dark:text-white font-bold block text-xs">Geadviseerde Aanpak:</strong>
|
||||
<p id="breakdownSummaryText" class="text-slate-600 dark:text-slate-300 text-[11px] mt-0.5 leading-relaxed"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bestemming Kiezer Tabs -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-bold text-slate-900 dark:text-white">Kies Export Bestemming:</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button type="button" onclick="setBreakdownDestination('kanban')" id="destBtn-kanban" class="dest-btn p-2.5 rounded-xl border text-xs font-bold transition flex flex-col items-center justify-center space-y-1 cursor-pointer bg-indigo-50 dark:bg-indigo-950/40 border-indigo-500 text-indigo-700 dark:text-indigo-300">
|
||||
<i data-lucide="trello" class="w-4 h-4"></i>
|
||||
<span>Kanban Bord</span>
|
||||
</button>
|
||||
<button type="button" onclick="setBreakdownDestination('timeline')" id="destBtn-timeline" class="dest-btn p-2.5 rounded-xl border text-xs font-semibold transition flex flex-col items-center justify-center space-y-1 cursor-pointer bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:border-slate-300">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||
<span>Tijdlijn / Gantt</span>
|
||||
</button>
|
||||
<button type="button" onclick="setBreakdownDestination('planner')" id="destBtn-planner" class="dest-btn p-2.5 rounded-xl border text-xs font-semibold transition flex flex-col items-center justify-center space-y-1 cursor-pointer bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:border-slate-300">
|
||||
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||
<span>Dagplanner</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bestemmings-specifieke opties -->
|
||||
<!-- Opties voor Kanban Bord -->
|
||||
<div id="destOptions-kanban" class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs">
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">Kanban Bord</label>
|
||||
<select id="breakdownBoardSelect" onchange="onBreakdownBoardChange(this.value)" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">Borden laden...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">Kolom / Status</label>
|
||||
<select id="breakdownLaneSelect" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="0">Standaard (eerste kolom)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opties voor Tijdlijn -->
|
||||
<div id="destOptions-timeline" class="hidden grid grid-cols-1 sm:grid-cols-3 gap-3 p-3 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs">
|
||||
<div class="sm:col-span-1">
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">Project Bord</label>
|
||||
<select id="breakdownTimelineBoardSelect" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">Borden laden...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">Startdatum</label>
|
||||
<input type="date" id="breakdownStartDate" value="<?= date('Y-m-d') ?>" 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-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">Einddatum</label>
|
||||
<input type="date" id="breakdownEndDate" value="<?= date('Y-m-d', strtotime('+3 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-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opties voor Dagplanner -->
|
||||
<div id="destOptions-planner" class="hidden p-3 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs">
|
||||
<div class="max-w-xs">
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">Plandatum</label>
|
||||
<input type="date" id="breakdownPlanDate" value="<?= date('Y-m-d') ?>" 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-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Taken Lijst -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
|
||||
<span>Gegenereerde Taken</span>
|
||||
<span id="breakdownTasksBadge" class="px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-400 font-mono text-[10px]">0</span>
|
||||
</span>
|
||||
<button type="button" onclick="addManualBreakdownItem()" class="text-xs text-indigo-600 dark:text-indigo-400 font-bold hover:underline flex items-center space-x-1 cursor-pointer">
|
||||
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
|
||||
<span>Taak toevoegen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="breakdownTasksContainer" class="space-y-2.5">
|
||||
<!-- Wordt dynamisch gevuld met renderBreakdownTasks() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="pt-3 border-t border-slate-100 dark:border-slate-800 flex items-center justify-between flex-shrink-0">
|
||||
<button type="button" onclick="regenerateBreakdown()" id="breakdownRegenBtn" class="px-3.5 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-1.5">
|
||||
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
|
||||
<span>Opnieuw Genereren</span>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button type="button" onclick="closeAiBreakdownModal()" 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-300 text-xs font-semibold transition cursor-pointer">
|
||||
Annuleren
|
||||
</button>
|
||||
<button type="button" onclick="submitBreakdownExport()" id="breakdownExportBtn" class="px-5 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer flex items-center space-x-2">
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
<span id="breakdownExportBtnText">Exporteer Taken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const sessionId = <?= $sessionId ?>;
|
||||
|
||||
@@ -546,18 +724,364 @@ async function aiSuggestRoadmapMeta(btn) {
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AI IDEE UITWERKEN & EXPORTEREN LOGICA
|
||||
// ==========================================
|
||||
let currentBreakdownIdea = '';
|
||||
let currentBreakdownCardId = 0;
|
||||
let currentBreakdownItems = [];
|
||||
let currentDestination = 'kanban';
|
||||
let cachedBoards = [];
|
||||
|
||||
async function openAiBreakdownModal(cardId, content) {
|
||||
currentBreakdownCardId = cardId;
|
||||
currentBreakdownIdea = content;
|
||||
currentBreakdownItems = [];
|
||||
|
||||
document.getElementById('breakdownIdeaText').innerText = `"${content}"`;
|
||||
document.getElementById('breakdownSessionLabel').innerText = `Sessie: <?= htmlspecialchars(addslashes($session['title'])) ?> (<?= htmlspecialchars(addslashes($session['theme'])) ?>)`;
|
||||
|
||||
const modal = document.getElementById('aiBreakdownModal');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Reset weergave
|
||||
document.getElementById('breakdownLoading').classList.remove('hidden');
|
||||
document.getElementById('breakdownResult').classList.add('hidden');
|
||||
document.getElementById('breakdownError').classList.add('hidden');
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
// Haal borden op
|
||||
await loadBoardsForBreakdown();
|
||||
|
||||
// Haal AI taken op
|
||||
await fetchAiBreakdown(content);
|
||||
}
|
||||
|
||||
function closeAiBreakdownModal() {
|
||||
const modal = document.getElementById('aiBreakdownModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function regenerateBreakdown() {
|
||||
if (!currentBreakdownIdea) return;
|
||||
const regenBtn = document.getElementById('breakdownRegenBtn');
|
||||
if (regenBtn) {
|
||||
regenBtn.disabled = true;
|
||||
regenBtn.innerHTML = '<i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Bezig...</span>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
document.getElementById('breakdownLoading').classList.remove('hidden');
|
||||
document.getElementById('breakdownResult').classList.add('hidden');
|
||||
document.getElementById('breakdownError').classList.add('hidden');
|
||||
|
||||
await fetchAiBreakdown(currentBreakdownIdea);
|
||||
|
||||
if (regenBtn) {
|
||||
regenBtn.disabled = false;
|
||||
regenBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i><span>Opnieuw Genereren</span>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAiBreakdown(content) {
|
||||
try {
|
||||
const res = await fetch('/api/ai.php?action=breakdown_idea', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
content: content,
|
||||
session_title: '<?= htmlspecialchars(addslashes($session['title'])) ?>',
|
||||
theme: '<?= htmlspecialchars(addslashes($session['theme'])) ?>',
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success || !data.items || data.items.length === 0) {
|
||||
document.getElementById('breakdownLoading').classList.add('hidden');
|
||||
document.getElementById('breakdownError').classList.remove('hidden');
|
||||
document.getElementById('breakdownErrorMsg').innerText = data.error || 'Geen taken gegenereerd door AI.';
|
||||
return;
|
||||
}
|
||||
|
||||
currentBreakdownItems = data.items.map((it, idx) => ({
|
||||
id: idx + 1,
|
||||
selected: true,
|
||||
title: it.title || 'Nieuwe taak',
|
||||
description: it.description || '',
|
||||
priority: it.priority || 'P2',
|
||||
estimated_minutes: parseInt(it.estimated_minutes) || 45,
|
||||
color: it.color || 'indigo',
|
||||
category: it.category || 'Feature',
|
||||
}));
|
||||
|
||||
document.getElementById('breakdownSummaryText').innerText = data.summary || 'Dit idee is opgesplitst in behapbare deeltaken voor snelle executie.';
|
||||
|
||||
renderBreakdownTasks();
|
||||
|
||||
document.getElementById('breakdownLoading').classList.add('hidden');
|
||||
document.getElementById('breakdownResult').classList.remove('hidden');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (err) {
|
||||
alert('Netwerkfout bij ophalen van AI advies');
|
||||
if (btn) {
|
||||
btn.innerHTML = origHtml;
|
||||
document.getElementById('breakdownLoading').classList.add('hidden');
|
||||
document.getElementById('breakdownError').classList.remove('hidden');
|
||||
document.getElementById('breakdownErrorMsg').innerText = 'Netwerkfout bij communicatie met AI: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreakdownTasks() {
|
||||
const container = document.getElementById('breakdownTasksContainer');
|
||||
const badge = document.getElementById('breakdownTasksBadge');
|
||||
if (!container) return;
|
||||
|
||||
badge.innerText = currentBreakdownItems.length;
|
||||
container.innerHTML = '';
|
||||
|
||||
currentBreakdownItems.forEach((item, idx) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'p-3 rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-[#0c0f17] space-y-2 text-xs transition hover:border-indigo-300 dark:hover:border-indigo-700/60 shadow-2xs';
|
||||
div.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center space-x-2 flex-1">
|
||||
<input type="checkbox" onchange="toggleBreakdownItemSelect(${idx}, this.checked)" ${item.selected ? 'checked' : ''} class="w-4 h-4 rounded text-indigo-600 focus:ring-0 border-slate-300 dark:border-slate-700 cursor-pointer">
|
||||
<input type="text" value="${escapeHtml(item.title)}" onchange="updateBreakdownItemField(${idx}, 'title', this.value)" placeholder="Taaktitel" class="w-full font-bold text-slate-900 dark:text-white bg-transparent border-b border-transparent hover:border-slate-300 focus:border-indigo-500 focus:outline-none px-1 py-0.5 text-xs">
|
||||
</div>
|
||||
<div class="flex items-center space-x-1.5 flex-shrink-0">
|
||||
<select onchange="updateBreakdownItemField(${idx}, 'priority', this.value)" class="bg-slate-100 dark:bg-slate-800 text-[10px] font-bold rounded-lg px-2 py-1 text-slate-700 dark:text-slate-300 border-none focus:ring-0 cursor-pointer">
|
||||
<option value="P1" ${item.priority === 'P1' ? 'selected' : ''}>P1 (Brandend)</option>
|
||||
<option value="P2" ${item.priority === 'P2' ? 'selected' : ''}>P2 (Normaal)</option>
|
||||
<option value="P3" ${item.priority === 'P3' ? 'selected' : ''}>P3 (Laag)</option>
|
||||
</select>
|
||||
<select onchange="updateBreakdownItemField(${idx}, 'estimated_minutes', parseInt(this.value))" class="bg-slate-100 dark:bg-slate-800 text-[10px] font-semibold rounded-lg px-2 py-1 text-slate-700 dark:text-slate-300 border-none focus:ring-0 cursor-pointer">
|
||||
<option value="15" ${item.estimated_minutes === 15 ? 'selected' : ''}>15m</option>
|
||||
<option value="30" ${item.estimated_minutes === 30 ? 'selected' : ''}>30m</option>
|
||||
<option value="45" ${item.estimated_minutes === 45 ? 'selected' : ''}>45m</option>
|
||||
<option value="60" ${item.estimated_minutes === 60 ? 'selected' : ''}>60m</option>
|
||||
<option value="90" ${item.estimated_minutes === 90 ? 'selected' : ''}>90m</option>
|
||||
<option value="120" ${item.estimated_minutes === 120 ? 'selected' : ''}>120m</option>
|
||||
</select>
|
||||
<button type="button" onclick="removeBreakdownItem(${idx})" class="p-1 text-slate-400 hover:text-rose-500 rounded transition cursor-pointer" title="Verwijder taak">
|
||||
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea onchange="updateBreakdownItemField(${idx}, 'description', this.value)" placeholder="Toelichting & acceptatiecriteria..." rows="2" class="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-lg p-2 text-[11px] text-slate-700 dark:text-slate-300 focus:outline-none focus:border-indigo-500 leading-relaxed resize-none">${escapeHtml(item.description)}</textarea>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
updateExportButtonText();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return text.toString()
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function toggleBreakdownItemSelect(idx, isChecked) {
|
||||
if (currentBreakdownItems[idx]) {
|
||||
currentBreakdownItems[idx].selected = isChecked;
|
||||
updateExportButtonText();
|
||||
}
|
||||
}
|
||||
|
||||
function updateBreakdownItemField(idx, field, value) {
|
||||
if (currentBreakdownItems[idx]) {
|
||||
currentBreakdownItems[idx][field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function addManualBreakdownItem() {
|
||||
currentBreakdownItems.push({
|
||||
id: Date.now(),
|
||||
selected: true,
|
||||
title: 'Nieuwe deel-taak',
|
||||
description: 'Context en criteria toevoegen...',
|
||||
priority: 'P2',
|
||||
estimated_minutes: 30,
|
||||
color: 'indigo',
|
||||
category: 'Feature'
|
||||
});
|
||||
renderBreakdownTasks();
|
||||
}
|
||||
|
||||
function removeBreakdownItem(idx) {
|
||||
currentBreakdownItems.splice(idx, 1);
|
||||
renderBreakdownTasks();
|
||||
}
|
||||
|
||||
function setBreakdownDestination(dest) {
|
||||
currentDestination = dest;
|
||||
const dests = ['kanban', 'timeline', 'planner'];
|
||||
|
||||
dests.forEach(d => {
|
||||
const btn = document.getElementById('destBtn-' + d);
|
||||
const opt = document.getElementById('destOptions-' + d);
|
||||
if (d === dest) {
|
||||
btn.className = 'dest-btn p-2.5 rounded-xl border text-xs font-bold transition flex flex-col items-center justify-center space-y-1 cursor-pointer bg-indigo-50 dark:bg-indigo-950/40 border-indigo-500 text-indigo-700 dark:text-indigo-300';
|
||||
if (opt) opt.classList.remove('hidden');
|
||||
} else {
|
||||
btn.className = 'dest-btn p-2.5 rounded-xl border text-xs font-semibold transition flex flex-col items-center justify-center space-y-1 cursor-pointer bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:border-slate-300';
|
||||
if (opt) opt.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
updateExportButtonText();
|
||||
}
|
||||
|
||||
function updateExportButtonText() {
|
||||
const selectedCount = currentBreakdownItems.filter(i => i.selected).length;
|
||||
const btnText = document.getElementById('breakdownExportBtnText');
|
||||
if (!btnText) return;
|
||||
|
||||
let targetLabel = 'Kanban Bord';
|
||||
if (currentDestination === 'timeline') targetLabel = 'Tijdlijn';
|
||||
if (currentDestination === 'planner') targetLabel = 'Dagplanner';
|
||||
|
||||
btnText.innerText = `Exporteer ${selectedCount} ${selectedCount === 1 ? 'Taak' : 'Taken'} naar ${targetLabel}`;
|
||||
}
|
||||
|
||||
async function loadBoardsForBreakdown() {
|
||||
try {
|
||||
const res = await fetch('/api/board.php?action=get_boards');
|
||||
const data = await res.json();
|
||||
if (data.success && data.boards) {
|
||||
cachedBoards = data.boards;
|
||||
const selectKanban = document.getElementById('breakdownBoardSelect');
|
||||
const selectTimeline = document.getElementById('breakdownTimelineBoardSelect');
|
||||
|
||||
selectKanban.innerHTML = '';
|
||||
selectTimeline.innerHTML = '';
|
||||
|
||||
cachedBoards.forEach(b => {
|
||||
const opt1 = document.createElement('option');
|
||||
opt1.value = b.id;
|
||||
opt1.textContent = b.title;
|
||||
selectKanban.appendChild(opt1);
|
||||
|
||||
const opt2 = document.createElement('option');
|
||||
opt2.value = b.id;
|
||||
opt2.textContent = b.title;
|
||||
selectTimeline.appendChild(opt2);
|
||||
});
|
||||
|
||||
if (cachedBoards.length > 0) {
|
||||
await onBreakdownBoardChange(cachedBoards[0].id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fout bij ophalen van borden:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onBreakdownBoardChange(boardId) {
|
||||
if (!boardId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/board.php?action=get_board_data&board_id=${boardId}`);
|
||||
const data = await res.json();
|
||||
const laneSelect = document.getElementById('breakdownLaneSelect');
|
||||
laneSelect.innerHTML = '';
|
||||
|
||||
if (data.success && data.lanes && data.lanes.length > 0) {
|
||||
data.lanes.forEach(lane => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = lane.id;
|
||||
opt.textContent = lane.name;
|
||||
laneSelect.appendChild(opt);
|
||||
});
|
||||
} else {
|
||||
laneSelect.innerHTML = '<option value="0">Standaard kolom</option>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fout bij ophalen van lanes:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBreakdownExport() {
|
||||
const selectedItems = currentBreakdownItems.filter(i => i.selected);
|
||||
if (selectedItems.length === 0) {
|
||||
alert('Selecteer minimaal één taak om te exporteren.');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('breakdownExportBtn');
|
||||
const btnText = document.getElementById('breakdownExportBtnText');
|
||||
const origHtml = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="flex items-center space-x-2"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Exporteren...</span></span>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
const payload = {
|
||||
destination: currentDestination,
|
||||
items: selectedItems,
|
||||
};
|
||||
|
||||
if (currentDestination === 'kanban') {
|
||||
payload.board_id = document.getElementById('breakdownBoardSelect').value;
|
||||
payload.lane_id = document.getElementById('breakdownLaneSelect').value;
|
||||
} else if (currentDestination === 'timeline') {
|
||||
payload.board_id = document.getElementById('breakdownTimelineBoardSelect').value;
|
||||
payload.start_date = document.getElementById('breakdownStartDate').value;
|
||||
payload.end_date = document.getElementById('breakdownEndDate').value;
|
||||
} else if (currentDestination === 'planner') {
|
||||
payload.plan_date = document.getElementById('breakdownPlanDate').value;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai.php?action=save_breakdown_items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
closeAiBreakdownModal();
|
||||
let destMsg = '';
|
||||
let targetUrl = '';
|
||||
if (currentDestination === 'kanban') {
|
||||
destMsg = `Succesvol ${data.created_count} taken toegevoegd aan het Kanban Bord!`;
|
||||
targetUrl = `/board.php?id=${payload.board_id}`;
|
||||
} else if (currentDestination === 'timeline') {
|
||||
destMsg = `Succesvol ${data.created_count} taken gepland op de Project Tijdlijn!`;
|
||||
targetUrl = `/timeline.php?board_id=${payload.board_id}`;
|
||||
} else {
|
||||
destMsg = `Succesvol ${data.created_count} taken toegevoegd aan uw Dagplanner!`;
|
||||
targetUrl = `/planner.php?date=${payload.plan_date}`;
|
||||
}
|
||||
|
||||
if (confirm(`${destMsg}\n\nWilt u nu direct naar deze pagina gaan om ze te bekijken?`)) {
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
} else {
|
||||
alert('Fout bij exporteren van taken: ' + (data.error || 'Onbekende fout'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Netwerkfout bij exporteren: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
btn.innerHTML = origHtml;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeAddCardModal();
|
||||
closeConvertModal();
|
||||
closeAiBreakdownModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -293,3 +293,23 @@ CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||
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;
|
||||
|
||||
|
||||
+320
-52
@@ -8,21 +8,185 @@ 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'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen AI API-sleutel geconfigureerd. Stel deze in via Systeembeheer (admin.php).',
|
||||
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 = $aiSettings['ai_api_key'];
|
||||
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gemini-1.5-pro';
|
||||
$temperature = $options['temperature'] ?? 0.3;
|
||||
$maxTokens = $options['max_tokens'] ?? 2048;
|
||||
$jsonMode = !empty($options['json_mode']);
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
$endpoint = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key=" . urlencode($apiKey);
|
||||
|
||||
$response = fociz_http_request($endpoint, [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 45,
|
||||
]);
|
||||
|
||||
if ($response['status'] !== 200 || empty($response['json']['candidates'][0]['content']['parts'][0]['text'])) {
|
||||
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "Gemini API aanroep mislukt (HTTP {$response['status']})");
|
||||
error_log("fociz_ai_chat_gemini fout: {$errMsg}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$rawContent = trim($response['json']['candidates'][0]['content']['parts'][0]['text']);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $cleanContent,
|
||||
'json' => $parsedJson,
|
||||
'model' => $model,
|
||||
'provider' => 'gemini',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = $aiSettings['ai_api_key'];
|
||||
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gpt-4o-mini';
|
||||
$temperature = $options['temperature'] ?? 0.3;
|
||||
@@ -51,8 +215,8 @@ function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
]);
|
||||
|
||||
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 = $response['json']['error']['message'] ?? ($response['error'] ?? "OpenAI aanroep mislukt (HTTP {$response['status']})");
|
||||
error_log("fociz_ai_chat_openai fout: {$errMsg}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
@@ -71,6 +235,107 @@ function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
'content' => $rawContent,
|
||||
'json' => $parsedJson,
|
||||
'model' => $response['json']['model'] ?? $model,
|
||||
'provider' => 'openai',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return fociz_ai_chat_gemini($messages, $options, $aiSettings);
|
||||
}
|
||||
|
||||
return fociz_ai_chat_openai($messages, $options, $aiSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'] || empty($aiRes['json']['items'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $aiRes['error'] ?? 'Kon het idee niet ontleden in taken via AI.',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'summary' => $aiRes['json']['summary'] ?? '',
|
||||
'items' => $aiRes['json']['items'],
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -81,7 +346,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 +374,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,7 +386,7 @@ 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): array {
|
||||
$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"
|
||||
@@ -130,7 +395,7 @@ function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën."],
|
||||
], ['json_mode' => true]);
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
|
||||
@@ -142,7 +407,7 @@ function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count
|
||||
/**
|
||||
* 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,7 +419,7 @@ 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'];
|
||||
@@ -164,29 +429,44 @@ function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: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'];
|
||||
@@ -196,28 +476,30 @@ function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySett
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een persoonlijke AI dagbriefing op basis van taken, afspraken en Jira tickets
|
||||
* 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 taken van vandaag
|
||||
* @param array $meetings Outlook meetings van vandaag
|
||||
* @param array $jiraIssues Jira tickets (P1/blokkades)
|
||||
* @param array $tasks Openstaande interne taken van vandaag
|
||||
* @param array $busyBlocks Geanonimiseerde bezette tijdsblokken (start/end)
|
||||
* @param array $unfinishedYest Gisteren niet voltooide taken
|
||||
* @return array ['success' => bool, 'briefing' => string, 'focus_tip' => string, 'warnings' => array]
|
||||
* @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 $meetings,
|
||||
array $jiraIssues = [],
|
||||
array $unfinishedYest = []
|
||||
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',
|
||||
@@ -225,46 +507,31 @@ function fociz_ai_daily_briefing(
|
||||
'duration' => (int)($t['duration_minutes'] ?? 30),
|
||||
], array_slice($tasks, 0, 10));
|
||||
|
||||
$meetingSummary = array_map(fn($e) => [
|
||||
'subject' => $e['subject'] ?? 'Afspraak',
|
||||
'start' => $e['start'] ?? null,
|
||||
'end' => $e['end'] ?? null,
|
||||
], array_slice($meetings, 0, 8));
|
||||
|
||||
$jiraSummary = array_map(fn($j) => [
|
||||
'key' => $j['key'] ?? '',
|
||||
'title' => $j['summary'] ?? '',
|
||||
'priority' => $j['priority'] ?? 'Medium',
|
||||
'status' => $j['status'] ?? 'Open',
|
||||
], array_slice(array_filter($jiraIssues, fn($j) => in_array($j['priority'] ?? '', ['Highest', 'High'])), 0, 5));
|
||||
|
||||
$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 praktisch focustip. "
|
||||
. "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 of meeting vandaag (max 15 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 — alleen als er iets opvalt (gisteren niet afgerond, urgent Jira, overvolle dag)";
|
||||
. "- 'warnings': array van strings, max 2 items (bijv. gisteren niet afgerond of overvolle dag)";
|
||||
|
||||
$userPrompt = json_encode([
|
||||
'date' => $dateLabel,
|
||||
'tasks_today' => $taskSummary,
|
||||
'meetings_today' => $meetingSummary,
|
||||
'urgent_jira' => $jiraSummary,
|
||||
'busy_blocks_count' => count($busyBlocks),
|
||||
'unfinished_yesterday' => $yesterdaySummary,
|
||||
'total_tasks' => count($tasks),
|
||||
'total_meetings' => count($meetings),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true, 'temperature' => 0.6, 'max_tokens' => 300]);
|
||||
], ['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'];
|
||||
@@ -276,5 +543,6 @@ function fociz_ai_daily_briefing(
|
||||
'focus_task' => $aiRes['json']['focus_task'] ?? '',
|
||||
'tip' => $aiRes['json']['tip'] ?? '',
|
||||
'warnings' => $aiRes['json']['warnings'] ?? [],
|
||||
'model' => $aiRes['model'] ?? 'AI',
|
||||
];
|
||||
}
|
||||
|
||||
+305
-9
@@ -295,15 +295,23 @@ function fociz_send_new_ip_verification(int $userId, string $email, string $name
|
||||
|
||||
function start_secure_session(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Dagsessie: 24 uur (86400s) zodat server-garbage collectors de sessie niet tussentijds wissen
|
||||
ini_set('session.gc_maxlifetime', '86400');
|
||||
ini_set('session.cookie_lifetime', '86400');
|
||||
$isSecure = is_https_request();
|
||||
|
||||
// OWASP ASVS v4.0 §3.4 & SOC 2 CC6.6 Cryptografische Hardening
|
||||
ini_set('session.sid_length', '48');
|
||||
ini_set('session.sid_bits_per_character', '6');
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.use_trans_sid', '0');
|
||||
ini_set('session.gc_maxlifetime', '86400');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
|
||||
// Cookie prefix: __Host- garandeert dat cookies niet door subdomeinen kunnen worden overschreven
|
||||
$sessionName = $isSecure ? '__Host-fociz_session' : 'fociz_session';
|
||||
session_name($sessionName);
|
||||
|
||||
$isSecure = is_https_request();
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 86400,
|
||||
'lifetime' => 0, // Browser sessiecookie: vervalt direct bij afsluiten browser conform ISO 27001 / NIST SP 800-63B
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $isSecure,
|
||||
@@ -343,11 +351,240 @@ function ensure_remember_token_columns(PDO $db): void {
|
||||
// Schoon uitsluitend verlopen tokens van gisteren op, wis NOOIT actieve tokens van vandaag
|
||||
$db->exec("UPDATE `users` SET `remember_token` = NULL, `remember_token_expires_at` = NULL WHERE `remember_token_expires_at` IS NOT NULL AND `remember_token_expires_at` < NOW()");
|
||||
}
|
||||
|
||||
$resActivity = $db->query("SHOW COLUMNS FROM `users` LIKE 'last_activity_at'");
|
||||
if (!$resActivity || $resActivity->rowCount() === 0) {
|
||||
$db->exec("ALTER TABLE `users` ADD COLUMN `last_activity_at` DATETIME NULL AFTER `remember_token_expires_at`");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien al aanwezig of tijdens migraties
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SOC 2 & ISO 27001 Actieve Sessietabel
|
||||
* Garandeert dat de tabel user_sessions aanwezig is voor multi-session tracking en remote revocation
|
||||
*/
|
||||
function ensure_sessions_table(PDO $db): void {
|
||||
static $sessionsTableChecked = false;
|
||||
if ($sessionsTableChecked) return;
|
||||
$sessionsTableChecked = true;
|
||||
|
||||
try {
|
||||
$db->exec("
|
||||
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;
|
||||
");
|
||||
} catch (Throwable $e) {
|
||||
error_log("ensure_sessions_table notice: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zet User-Agent om naar een herkenbare apparaatnaam (SOC 2 / ISO 27001 UI)
|
||||
*/
|
||||
function fociz_get_device_name(?string $userAgent): string {
|
||||
if (empty($userAgent)) return 'Onbekend apparaat';
|
||||
$ua = $userAgent;
|
||||
|
||||
$platform = 'Onbekend OS';
|
||||
if (stripos($ua, 'Windows NT 10.0') !== false) $platform = 'Windows 10/11';
|
||||
elseif (stripos($ua, 'Windows') !== false) $platform = 'Windows';
|
||||
elseif (stripos($ua, 'Macintosh') !== false || stripos($ua, 'Mac OS X') !== false) $platform = 'macOS';
|
||||
elseif (stripos($ua, 'iPhone') !== false) $platform = 'iPhone';
|
||||
elseif (stripos($ua, 'iPad') !== false) $platform = 'iPad';
|
||||
elseif (stripos($ua, 'Android') !== false) $platform = 'Android';
|
||||
elseif (stripos($ua, 'Linux') !== false) $platform = 'Linux';
|
||||
|
||||
$browser = 'Browser';
|
||||
if (stripos($ua, 'Edg/') !== false) $browser = 'Edge';
|
||||
elseif (stripos($ua, 'Chrome/') !== false && stripos($ua, 'Chromium') === false) $browser = 'Chrome';
|
||||
elseif (stripos($ua, 'Safari/') !== false && stripos($ua, 'Chrome') === false) $browser = 'Safari';
|
||||
elseif (stripos($ua, 'Firefox/') !== false) $browser = 'Firefox';
|
||||
elseif (stripos($ua, 'Opera') !== false || stripos($ua, 'OPR/') !== false) $browser = 'Opera';
|
||||
|
||||
return "{$browser} op {$platform}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Registreert of vernieuwt een actieve sessie in user_sessions (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
*/
|
||||
function fociz_register_active_session(int $userId, string $sessionId, int $maxIdleSeconds): void {
|
||||
try {
|
||||
if (empty($sessionId) || $userId <= 0) return;
|
||||
$db = get_db_connection();
|
||||
ensure_sessions_table($db);
|
||||
|
||||
$hash = hash('sha256', $sessionId);
|
||||
$ip = fociz_get_client_ip();
|
||||
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
|
||||
$deviceName = fociz_get_device_name($ua);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + $maxIdleSeconds);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO user_sessions (user_id, session_id_hash, ip_address, user_agent, device_name, is_active, last_activity_at, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, NOW(), ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ip_address = VALUES(ip_address),
|
||||
user_agent = VALUES(user_agent),
|
||||
device_name = VALUES(device_name),
|
||||
is_active = 1,
|
||||
last_activity_at = NOW(),
|
||||
expires_at = VALUES(expires_at)
|
||||
");
|
||||
$stmt->execute([$userId, $hash, $ip, $ua, $deviceName, $expiresAt]);
|
||||
|
||||
// Verwijder oude verlopen sessies (> 7 dagen geleden verlopen)
|
||||
$db->exec("DELETE FROM user_sessions WHERE expires_at < NOW() - INTERVAL 7 DAY");
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_register_active_session error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Werkt de activiteitstijd en expiratiedatum bij van een actieve sessie
|
||||
*/
|
||||
function fociz_touch_session(string $sessionId, int $maxIdleSeconds): void {
|
||||
try {
|
||||
if (empty($sessionId)) return;
|
||||
$db = get_db_connection();
|
||||
$hash = hash('sha256', $sessionId);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + $maxIdleSeconds);
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET last_activity_at = NOW(), expires_at = ? WHERE session_id_hash = ? AND is_active = 1");
|
||||
$stmt->execute([$expiresAt, $hash]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_touch_session error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roteert een sessie-ID in de database (OWASP ASVS v4.0 §3.3)
|
||||
*/
|
||||
function fociz_rotate_session(string $oldSessionId, string $newSessionId): void {
|
||||
try {
|
||||
if (empty($oldSessionId) || empty($newSessionId) || $oldSessionId === $newSessionId) return;
|
||||
$db = get_db_connection();
|
||||
$oldHash = hash('sha256', $oldSessionId);
|
||||
$newHash = hash('sha256', $newSessionId);
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET session_id_hash = ? WHERE session_id_hash = ?");
|
||||
$stmt->execute([$newHash, $oldHash]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_rotate_session error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleert of de huidige sessie nog actief is (of ingetrokken op afstand)
|
||||
*/
|
||||
function fociz_is_session_active(string $sessionId, int $userId): bool {
|
||||
try {
|
||||
if (empty($sessionId) || $userId <= 0) return false;
|
||||
$db = get_db_connection();
|
||||
ensure_sessions_table($db);
|
||||
|
||||
$hash = hash('sha256', $sessionId);
|
||||
$stmt = $db->prepare("SELECT is_active, expires_at FROM user_sessions WHERE session_id_hash = ? AND user_id = ?");
|
||||
$stmt->execute([$hash, $userId]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) return true; // Als sessie nog niet geregistreerd staat, laat het door (wordt geregistreerd)
|
||||
return (!empty($row['is_active']));
|
||||
} catch (Throwable $e) {
|
||||
return true; // Fallback op toegestaan bij databaseverstoring
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Beëindigt een sessie in de database (bij uitloggen of time-out)
|
||||
*/
|
||||
function fociz_terminate_session(string $sessionId): void {
|
||||
try {
|
||||
if (empty($sessionId)) return;
|
||||
$db = get_db_connection();
|
||||
$hash = hash('sha256', $sessionId);
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE session_id_hash = ?");
|
||||
$stmt->execute([$hash]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_terminate_session error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trekt alle overige sessies van een gebruiker in (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
*/
|
||||
function fociz_revoke_other_sessions(int $userId, string $currentSessionId): int {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_sessions_table($db);
|
||||
$currentHash = hash('sha256', $currentSessionId);
|
||||
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE user_id = ? AND session_id_hash != ? AND is_active = 1");
|
||||
$stmt->execute([$userId, $currentHash]);
|
||||
$revokedCount = $stmt->rowCount();
|
||||
|
||||
// Invalideer ook het dagsessietoken in de database zodat herstel op andere browsers direct stopt
|
||||
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmtClear->execute([$userId]);
|
||||
|
||||
fociz_log_audit($userId, 'REVOKE_OTHER_SESSIONS', 'user_sessions', $userId, ['revoked_count' => $revokedCount], 'SUCCESS');
|
||||
return $revokedCount;
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_revoke_other_sessions error: " . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trekt een specifieke sessie in op basis van ID
|
||||
*/
|
||||
function fociz_revoke_session_by_id(int $userId, int $sessionDbId): bool {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE id = ? AND user_id = ?");
|
||||
$stmt->execute([$sessionDbId, $userId]);
|
||||
fociz_log_audit($userId, 'SESSION_TERMINATED_REVOKED', 'user_sessions', $sessionDbId, ['session_db_id' => $sessionDbId], 'SUCCESS');
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt alle actieve sessies van een gebruiker op voor de profielpagina
|
||||
*/
|
||||
function fociz_get_user_active_sessions(int $userId, string $currentSessionId): array {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_sessions_table($db);
|
||||
$currentHash = hash('sha256', $currentSessionId);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, ip_address, user_agent, device_name, last_activity_at, created_at, (session_id_hash = ?) AS is_current
|
||||
FROM user_sessions
|
||||
WHERE user_id = ? AND is_active = 1
|
||||
ORDER BY is_current DESC, last_activity_at DESC
|
||||
");
|
||||
$stmt->execute([$currentHash, $userId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Garandeert dat de kolom voor notificatie-instellingen aanwezig is in MariaDB
|
||||
*/
|
||||
@@ -532,7 +769,7 @@ function issue_day_session_token(int $userId): string {
|
||||
|
||||
$db = get_db_connection();
|
||||
ensure_remember_token_columns($db);
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = ?, remember_token_expires_at = ? WHERE id = ?");
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = ?, remember_token_expires_at = ?, last_activity_at = NOW() WHERE id = ?");
|
||||
$stmt->execute([$token, $expiresAt, $userId]);
|
||||
|
||||
$isSecure = is_https_request();
|
||||
@@ -591,6 +828,14 @@ function get_logged_in_user(): ?array {
|
||||
$today = date('Y-m-d');
|
||||
$now = time();
|
||||
|
||||
// Bepaal of dit een geautomatiseerde achtergrond poll is (zoals notifications, live sync of outlook polling)
|
||||
$isBackgroundPoll = !empty($_SERVER['HTTP_X_BACKGROUND_POLL']) ||
|
||||
(isset($_SERVER['REQUEST_URI']) && (
|
||||
strpos($_SERVER['REQUEST_URI'], '/api/live_sync.php') !== false ||
|
||||
(strpos($_SERVER['REQUEST_URI'], '/api/notifications.php') !== false && ($_GET['action'] ?? '') === 'get_events') ||
|
||||
(strpos($_SERVER['REQUEST_URI'], '/api/planner.php') !== false && ($_GET['action'] ?? '') === 'check_outlook_sync')
|
||||
));
|
||||
|
||||
// 1. Controleer of de huidige sessie van een eerdere dag is (dagwissel om middernacht)
|
||||
if (!empty($_SESSION['auth_date']) && $_SESSION['auth_date'] !== $today) {
|
||||
$_SESSION = ['session_was_expired' => true];
|
||||
@@ -608,10 +853,22 @@ function get_logged_in_user(): ?array {
|
||||
|
||||
// 2. Controleer actieve PHP sessie (mits behorend bij vandaag én hetzelfde IP/fingerprint)
|
||||
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
|
||||
// Controleer of sessie op afstand is ingetrokken (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if (!fociz_is_session_active(session_id(), (int)$_SESSION['user_id'])) {
|
||||
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_TERMINATED_REVOKED', 'user_sessions', (int)$_SESSION['user_id'], ['reason' => 'remote_revocation'], 'WARNING');
|
||||
$_SESSION = ['session_was_expired' => true, 'session_revoked' => true];
|
||||
clear_day_session_cookie();
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dynamische inactiviteitstimeout conform NIST SP 800-63B / ISO 27001 / OWASP ASVS v4.0
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
if (!empty($_SESSION['last_activity']) && ($now - (int)$_SESSION['last_activity']) > $maxIdleSeconds) {
|
||||
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_IDLE_TIMEOUT', 'users', (int)$_SESSION['user_id'], ['idle_seconds' => $now - (int)$_SESSION['last_activity'], 'max_idle' => $maxIdleSeconds], 'WARNING');
|
||||
fociz_terminate_session(session_id());
|
||||
// Invalideer dagsessie token in database zodat herstel via cookie uitgesloten is
|
||||
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmtClear->execute([$_SESSION['user_id']]);
|
||||
@@ -627,6 +884,7 @@ function get_logged_in_user(): ?array {
|
||||
$maxSessionDuration = 36000;
|
||||
if (!empty($_SESSION['login_time']) && ($now - (int)$_SESSION['login_time']) > $maxSessionDuration) {
|
||||
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_ABSOLUTE_TIMEOUT', 'users', (int)$_SESSION['user_id'], ['duration' => $now - (int)$_SESSION['login_time']], 'WARNING');
|
||||
fociz_terminate_session(session_id());
|
||||
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmtClear->execute([$_SESSION['user_id']]);
|
||||
$_SESSION = ['session_was_expired' => true, 'absolute_timeout' => true];
|
||||
@@ -655,8 +913,10 @@ function get_logged_in_user(): ?array {
|
||||
if (empty($_SESSION['last_rotated'])) {
|
||||
$_SESSION['last_rotated'] = $now;
|
||||
} elseif (($now - (int)$_SESSION['last_rotated']) > 1800) {
|
||||
$oldSid = session_id();
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
fociz_rotate_session($oldSid, session_id());
|
||||
}
|
||||
$_SESSION['last_rotated'] = $now;
|
||||
}
|
||||
@@ -668,7 +928,20 @@ function get_logged_in_user(): ?array {
|
||||
$_SESSION['auth_date'] = $today;
|
||||
$_SESSION['auth_fingerprint'] = $clientFingerprint;
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
|
||||
// Achtergrondpolls verversen last_activity NIET; alleen daadwerkelijke gebruikersinteractie houdt de sessie actief
|
||||
if (!$isBackgroundPoll) {
|
||||
$_SESSION['last_activity'] = $now;
|
||||
if (empty($_SESSION['last_db_activity_update']) || ($now - (int)$_SESSION['last_db_activity_update']) > 60) {
|
||||
$_SESSION['last_db_activity_update'] = $now;
|
||||
try {
|
||||
$stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?");
|
||||
$stmtAct->execute([$_SESSION['user_id']]);
|
||||
fociz_touch_session(session_id(), $maxIdleSeconds);
|
||||
} catch (Throwable $e) {}
|
||||
}
|
||||
}
|
||||
|
||||
$user['avatar_url'] = fociz_get_valid_avatar_url($user['avatar_url'] ?? null, (int)$user['id']);
|
||||
$_SESSION['user_avatar_url'] = $user['avatar_url'];
|
||||
return $user;
|
||||
@@ -679,13 +952,25 @@ function get_logged_in_user(): ?array {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Fallback dagsessie-token: herstelt sessie na browserherstart (uitsluitend vandaag!)
|
||||
// 3. Fallback dagsessie-token: herstelt sessie na browserherstart (uitsluitend vandaag én mits niet inactief!)
|
||||
$dayToken = $_COOKIE['fociz_day_session'] ?? ($_COOKIE['fociz_remember'] ?? null);
|
||||
if (!empty($dayToken) && is_string($dayToken)) {
|
||||
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, avatar_url, theme, notification_settings, default_jira_filter_code, fociz_widget_layout, created_at FROM users WHERE remember_token = ? AND remember_token_expires_at >= NOW() AND DATE(remember_token_expires_at) = ?");
|
||||
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, avatar_url, theme, notification_settings, default_jira_filter_code, fociz_widget_layout, last_activity_at, last_login_at, created_at FROM users WHERE remember_token = ? AND remember_token_expires_at >= NOW() AND DATE(remember_token_expires_at) = ?");
|
||||
$stmt->execute([$dayToken, $today]);
|
||||
$user = $stmt->fetch();
|
||||
if ($user) {
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
// Controleer inactiviteit: als gebruiker te lang inactief is geweest, herstel weigeren en opnieuw inloggen
|
||||
$lastActTime = !empty($user['last_activity_at']) ? strtotime($user['last_activity_at']) : (!empty($user['last_login_at']) ? strtotime($user['last_login_at']) : 0);
|
||||
if ($lastActTime > 0 && ($now - $lastActTime) > $maxIdleSeconds) {
|
||||
fociz_log_audit((int)$user['id'], 'SESSION_IDLE_TIMEOUT_COOKIE', 'users', (int)$user['id'], ['idle_seconds' => $now - $lastActTime, 'max_idle' => $maxIdleSeconds], 'WARNING');
|
||||
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmtClear->execute([$user['id']]);
|
||||
$_SESSION = ['session_was_expired' => true, 'idle_timeout' => true];
|
||||
clear_day_session_cookie();
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentIp = fociz_get_client_ip();
|
||||
// Als het token op een ander, onbekend IP wordt aangeboden, weiger de automatische herstel
|
||||
if (!fociz_is_ip_known_for_user((int)$user['id'], $currentIp)) {
|
||||
@@ -705,6 +990,14 @@ function get_logged_in_user(): ?array {
|
||||
$_SESSION['last_activity'] = $now;
|
||||
$_SESSION['login_time'] = $now;
|
||||
$_SESSION['last_rotated'] = $now;
|
||||
$_SESSION['last_db_activity_update'] = $now;
|
||||
try {
|
||||
$stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?");
|
||||
$stmtAct->execute([$user['id']]);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
fociz_register_active_session((int)$user['id'], session_id(), $maxIdleSeconds);
|
||||
|
||||
$user['avatar_url'] = fociz_get_valid_avatar_url($user['avatar_url'] ?? null, (int)$user['id']);
|
||||
$_SESSION['user_avatar_url'] = $user['avatar_url'];
|
||||
return $user;
|
||||
@@ -753,9 +1046,12 @@ function require_auth(bool $json = false): array {
|
||||
}
|
||||
$wasExpired = !empty($_SESSION['session_was_expired']);
|
||||
$ipChanged = !empty($_SESSION['ip_changed']);
|
||||
unset($_SESSION['session_was_expired'], $_SESSION['ip_changed']);
|
||||
$wasRevoked = !empty($_SESSION['session_revoked']);
|
||||
unset($_SESSION['session_was_expired'], $_SESSION['ip_changed'], $_SESSION['session_revoked']);
|
||||
if ($ipChanged) {
|
||||
header('Location: /auth/login.php?ip_changed=1');
|
||||
} elseif ($wasRevoked) {
|
||||
header('Location: /auth/login.php?revoked=1');
|
||||
} elseif ($wasExpired) {
|
||||
header('Location: /auth/login.php?expired=1');
|
||||
} else {
|
||||
|
||||
+59
-10
@@ -106,26 +106,75 @@ function get_workday_settings(): array {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', '');
|
||||
$savedKeyRaw = get_system_setting('ai_api_key', '');
|
||||
$savedKey = '';
|
||||
if (!empty($savedKeyRaw)) {
|
||||
function get_ai_settings(?int $userId = null): array {
|
||||
if (!function_exists('fociz_decrypt')) {
|
||||
require_once __DIR__ . '/auth.php';
|
||||
}
|
||||
$savedKey = fociz_decrypt($savedKeyRaw);
|
||||
|
||||
$apiKey = '';
|
||||
$isPersonal = false;
|
||||
|
||||
// 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($row['access_token']);
|
||||
$isPersonal = true;
|
||||
} elseif (!empty($row['settings'])) {
|
||||
$userSettings = json_decode($row['settings'], true) ?: [];
|
||||
if (!empty($userSettings['api_key'])) {
|
||||
$apiKey = fociz_decrypt($userSettings['api_key']);
|
||||
$isPersonal = true;
|
||||
}
|
||||
$apiKey = !empty($savedKey) ? $savedKey : $envKey;
|
||||
}
|
||||
|
||||
if (!empty($row['settings'])) {
|
||||
$userSettings = json_decode($row['settings'], true) ?: [];
|
||||
if (!empty($userSettings['provider'])) {
|
||||
$personalProvider = $userSettings['provider'];
|
||||
}
|
||||
if (!empty($userSettings['model'])) {
|
||||
$personalModel = $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($savedKeyRaw);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback naar .env variabelen (GEMINI_API_KEY of OPENAI_API_KEY)
|
||||
if (empty($apiKey)) {
|
||||
$apiKey = fociz_env('GEMINI_API_KEY', '') ?: fociz_env('OPENAI_API_KEY', '');
|
||||
}
|
||||
|
||||
$provider = !empty($personalProvider) ? $personalProvider : get_system_setting('ai_provider', 'gemini');
|
||||
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-1.5-pro');
|
||||
|
||||
return [
|
||||
'ai_enabled' => (bool)get_system_setting('ai_enabled', !empty($apiKey)),
|
||||
'ai_provider' => get_system_setting('ai_provider', 'openai'),
|
||||
'ai_provider' => $provider,
|
||||
'ai_api_key' => $apiKey,
|
||||
'ai_model' => get_system_setting('ai_model', 'gpt-4o-mini'),
|
||||
'ai_model' => $model,
|
||||
'has_key' => !empty($apiKey),
|
||||
'is_personal_key'=> $isPersonal,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -5015,7 +5015,9 @@ async function checkOutlookChanges(isManual = false) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/planner.php?action=check_outlook_sync&plan_date=${encodeURIComponent(activePlannerDate)}&week_start=${encodeURIComponent(weekStartDate)}&week_end=${encodeURIComponent(weekEndDate)}&last_hash=${encodeURIComponent(currentSyncHash)}`);
|
||||
const res = await fetch(`/api/planner.php?action=check_outlook_sync&plan_date=${encodeURIComponent(activePlannerDate)}&week_start=${encodeURIComponent(weekStartDate)}&week_end=${encodeURIComponent(weekEndDate)}&last_hash=${encodeURIComponent(currentSyncHash)}`, {
|
||||
headers: isManual ? {} : { 'X-Background-Poll': '1' }
|
||||
});
|
||||
if (!res.ok) throw new Error('Netwerkfout bij synchronisatie controle');
|
||||
const data = await res.json();
|
||||
|
||||
|
||||
+349
-3
@@ -132,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-1.5-pro');
|
||||
$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 = [
|
||||
@@ -181,16 +223,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
} 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]);
|
||||
|
||||
// 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.";
|
||||
$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'] ?? '');
|
||||
@@ -278,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';
|
||||
?>
|
||||
@@ -849,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-1.5-pro" <?= ($userAiSettings['ai_model'] === 'gemini-1.5-pro' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Aanbevolen)</option>
|
||||
<option value="gemini-2.0-flash" <?= $userAiSettings['ai_model'] === 'gemini-2.0-flash' ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Razendsnel)</option>
|
||||
<option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</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">
|
||||
@@ -1073,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.
|
||||
</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>
|
||||
@@ -1173,8 +1434,20 @@ function filterAvatarCategory(category) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -1199,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'; ?>
|
||||
|
||||
@@ -81,6 +81,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$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']);
|
||||
}
|
||||
@@ -297,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>
|
||||
|
||||
Reference in New Issue
Block a user