diff --git a/api/integrations.php b/api/integrations.php
index 7c8656c..edefbfa 100644
--- a/api/integrations.php
+++ b/api/integrations.php
@@ -28,7 +28,7 @@ try {
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
$stmt->execute([$user['id']]);
$saved = json_decode($stmt->fetchColumn() ?: '{}', true);
- $apiToken = $saved['jira_api_token'] ?? '';
+ $apiToken = fociz_decrypt($saved['jira_api_token'] ?? '');
if (empty($domain)) $domain = $saved['jira_domain'] ?? '';
if (empty($email)) $email = $saved['jira_email'] ?? '';
}
@@ -40,7 +40,7 @@ try {
$settings = json_encode([
'jira_domain' => jira_normalize_domain($domain),
'jira_email' => $email,
- 'jira_api_token' => $apiToken,
+ 'jira_api_token' => fociz_encrypt($apiToken),
'account_name' => $jiraUser['displayName'] ?? '',
'account_id' => $jiraUser['accountId'] ?? '',
]);
diff --git a/auth/callback_microsoft.php b/auth/callback_microsoft.php
index d86525e..c64ef39 100644
--- a/auth/callback_microsoft.php
+++ b/auth/callback_microsoft.php
@@ -137,7 +137,12 @@ $saveStmt = $db->prepare("
settings = VALUES(settings),
last_sync_at = NOW()
");
-$saveStmt->execute([$user['id'], $accessToken, $refreshToken, json_encode($newSettings)]);
+$saveStmt->execute([
+ $user['id'],
+ fociz_encrypt($accessToken),
+ fociz_encrypt($refreshToken),
+ json_encode($newSettings)
+]);
// 8. Succesvol terugkeren naar de gewenste pagina of het Persoonlijk Profiel
$returnTo = $_SESSION['ms_oauth_return_to'] ?? '/profile.php';
diff --git a/auth/login.php b/auth/login.php
index 28f2741..968de55 100644
--- a/auth/login.php
+++ b/auth/login.php
@@ -22,13 +22,19 @@ if ($currentUser) {
$error = null;
$infoMessage = null;
if (isset($_GET['expired'])) {
- $infoMessage = "Uw dagsessie is om 23:59 beëindigd. Log opnieuw in om een nieuwe werkdag te starten.";
+ $infoMessage = "Uw dagsessie is beëindigd. Log opnieuw in om verder te gaan.";
+}
+if (isset($_GET['ip_changed'])) {
+ $infoMessage = "U bent ingelogd vanaf een andere netwerklocatie. Uit veiligheidsoverwegingen dient u opnieuw in te loggen met 2FA.";
+}
+if (isset($_GET['ip_verified'])) {
+ $infoMessage = "Nieuwe inloglocatie succesvol geverifieerd! U kunt nu veilig inloggen.";
}
$step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
// Terug naar stap 1
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
- unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email']);
+ unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name'], $_SESSION['pending_new_ip']);
header('Location: /auth/login.php');
exit;
}
@@ -50,11 +56,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
- // Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
- $_SESSION['pending_2fa_user_id'] = $user['id'];
- $_SESSION['pending_2fa_user_email'] = $user['email'];
- $_SESSION['pending_2fa_user_name'] = $user['name'];
- $step = 2;
+ // Controleer of de inloglocatie/IP bekend is
+ $clientIp = fociz_get_client_ip();
+ $isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
+
+ if (!$isIpKnown) {
+ // Stuur verificatie-mail en informeer gebruiker
+ fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
+ $error = "Nieuwe inloglocatie gedetecteerd ({$clientIp}). Er is een verificatie-e-mail gestuurd naar {$user['email']}. Klik op de link in de e-mail om deze locatie toe te staan.";
+ } else {
+ // Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
+ $_SESSION['pending_2fa_user_id'] = $user['id'];
+ $_SESSION['pending_2fa_user_email'] = $user['email'];
+ $_SESSION['pending_2fa_user_name'] = $user['name'];
+ $step = 2;
+ }
} else {
$error = "Onjuist e-mailadres of wachtwoord.";
}
@@ -105,12 +121,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($isValid) {
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
+ $clientIp = fociz_get_client_ip();
+ fociz_register_user_ip((int)$user['id'], $clientIp, true);
+
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['user_email'] = $user['email'];
$_SESSION['user_role'] = $user['role'];
$_SESSION['is_2fa_verified'] = true;
$_SESSION['auth_date'] = date('Y-m-d');
+ $_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
$_SESSION['last_activity'] = time();
@@ -249,10 +269,7 @@ $pageTitle = "Fociz — Inloggen";
diff --git a/auth/verify-ip.php b/auth/verify-ip.php
new file mode 100644
index 0000000..0892c25
--- /dev/null
+++ b/auth/verify-ip.php
@@ -0,0 +1,135 @@
+prepare("
+ SELECT kip.*, u.email, u.name
+ FROM user_known_ips kip
+ JOIN users u ON kip.user_id = u.id
+ WHERE kip.verify_token = ?
+ ");
+ $stmt->execute([$token]);
+ $record = $stmt->fetch();
+
+ if (!$record) {
+ $error = "Deze verificatielink is niet gevonden of al gebruikt.";
+ } elseif (!empty($record['verify_token_expires_at']) && strtotime($record['verify_token_expires_at']) < time()) {
+ $error = "Deze verificatielink is verlopen (geldigheidsduur was 30 minuten). Probeer opnieuw in te loggen om een nieuwe link te ontvangen.";
+ } else {
+ // Verifieer het IP-adres
+ $updateStmt = $db->prepare("
+ UPDATE user_known_ips
+ SET is_verified = 1, verify_token = NULL, verify_token_expires_at = NULL, last_seen_at = NOW()
+ WHERE id = ?
+ ");
+ $updateStmt->execute([$record['id']]);
+
+ $success = true;
+ $userEmail = $record['email'];
+ $ipAddress = $record['ip_address'];
+ }
+ } catch (Throwable $e) {
+ error_log("verify-ip.php error: " . $e->getMessage());
+ $error = "Er is een databasefout opgetreden bij het verifiëren.";
+ }
+}
+
+$pageTitle = "Fociz — Locatie Verificatie";
+?>
+
+
+
+
+
+
= htmlspecialchars($pageTitle) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Locatie Verificatie
+
Beveiligde inlogtoegang voor Fociz
+
+
+
+
+
+
+ Locatie Succesvol Geverifieerd!
+
+
+ Het IP-adres = htmlspecialchars($ipAddress) ?> is nu geautoriseerd voor uw account (= htmlspecialchars($userEmail) ?>).
+
+
+
+
+
+
+
+
+ Verificatie Mislukt
+
+
+ = htmlspecialchars($error ?? 'Onbekende fout.') ?>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/database.php b/config/database.php
index 7b62a46..0059fff 100644
--- a/config/database.php
+++ b/config/database.php
@@ -156,6 +156,10 @@ function ensure_tables_exist(PDO $pdo): void {
"CREATE TABLE IF NOT EXISTS `jira_queue_filters` (`id` INT AUTO_INCREMENT PRIMARY KEY, `filter_code` VARCHAR(50) NOT NULL UNIQUE, `filter_name` VARCHAR(120) NOT NULL, `project_key` VARCHAR(50) NULL, `jql_clause` TEXT NOT NULL, `description` VARCHAR(255) NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `sort_order` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// V2.0: Subtaken & Checklists
"CREATE TABLE IF NOT EXISTS `task_subtasks` (`id` INT AUTO_INCREMENT PRIMARY KEY, `task_id` INT NOT NULL, `user_id` INT NOT NULL, `title` VARCHAR(255) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`task_id`, `position`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+ // Beveiliging: IP tracking & locatie-verificatie
+ "ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45) NULL AFTER last_jira_check_at",
+ "ALTER TABLE users ADD COLUMN last_login_at DATETIME NULL AFTER last_login_ip",
+ "CREATE TABLE IF NOT EXISTS `user_known_ips` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `is_verified` TINYINT(1) NOT NULL DEFAULT 1, `verify_token` VARCHAR(64) NULL, `verify_token_expires_at` DATETIME NULL, `first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`), INDEX (`verify_token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($colUpgrades as $sql) {
try {
diff --git a/config/schema.sql b/config/schema.sql
index d16c1e3..407feb7 100644
--- a/config/schema.sql
+++ b/config/schema.sql
@@ -18,6 +18,8 @@ CREATE TABLE IF NOT EXISTS `users` (
`fociz_widget_layout` TEXT NULL,
`last_mail_check_at` DATETIME NULL,
`last_jira_check_at` DATETIME NULL,
+ `last_login_ip` VARCHAR(45) NULL,
+ `last_login_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -257,3 +259,19 @@ CREATE TABLE IF NOT EXISTS `task_subtasks` (
CONSTRAINT `fk_subtasks_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_subtasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Fociz Beveiliging: Bekende IP-adressen en verificatie per gebruiker
+CREATE TABLE IF NOT EXISTS `user_known_ips` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `user_id` INT NOT NULL,
+ `ip_address` VARCHAR(45) NOT NULL,
+ `user_agent` VARCHAR(255) NULL,
+ `is_verified` TINYINT(1) NOT NULL DEFAULT 1,
+ `verify_token` VARCHAR(64) NULL,
+ `verify_token_expires_at` DATETIME NULL,
+ `first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`),
+ INDEX (`verify_token`),
+ CONSTRAINT `fk_known_ips_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/includes/auth.php b/includes/auth.php
index a5dc805..7880c79 100644
--- a/includes/auth.php
+++ b/includes/auth.php
@@ -33,6 +33,188 @@ function is_https_request(): bool {
return false;
}
+/**
+ * AES-256-GCM / AES-256-CBC encryptie helper voor gevoelige tokens (OAuth, API keys)
+ */
+function fociz_encrypt(string $plaintext): string {
+ if (empty($plaintext)) return '';
+ $key = hash('sha256', defined('APP_SECRET') ? APP_SECRET : 'fociz-maria-db-super-secret-key-998877', true);
+ $iv = random_bytes(16);
+ $encrypted = openssl_encrypt($plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
+ if ($encrypted === false) return $plaintext;
+ return 'ENC:' . base64_encode($iv . $encrypted);
+}
+
+/**
+ * AES-256-CBC decryptie helper; decodeert indien versleuteld, laat legacy plaintext ongemoeid
+ */
+function fociz_decrypt(string $ciphertext): string {
+ if (empty($ciphertext)) return '';
+ if (!str_starts_with($ciphertext, 'ENC:')) {
+ return $ciphertext; // Plaintext legacy waarde
+ }
+ $raw = base64_decode(substr($ciphertext, 4), true);
+ if ($raw === false || strlen($raw) < 17) {
+ return $ciphertext;
+ }
+ $iv = substr($raw, 0, 16);
+ $encrypted = substr($raw, 16);
+ $key = hash('sha256', defined('APP_SECRET') ? APP_SECRET : 'fociz-maria-db-super-secret-key-998877', true);
+ $decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
+ return ($decrypted !== false) ? $decrypted : $ciphertext;
+}
+
+/**
+ * Bepaalt het actuele client IP-adres (rekening houdend met reverse proxies)
+ */
+function fociz_get_client_ip(): string {
+ $headers = [
+ 'HTTP_CF_CONNECTING_IP', // Cloudflare
+ 'HTTP_X_REAL_IP', // Nginx reverse proxy
+ 'HTTP_X_FORWARDED_FOR', // Standard proxy chain
+ 'REMOTE_ADDR' // Directe verbinding
+ ];
+
+ foreach ($headers as $header) {
+ if (!empty($_SERVER[$header])) {
+ $ipList = explode(',', $_SERVER[$header]);
+ $ip = trim($ipList[0]);
+ if (filter_var($ip, FILTER_VALIDATE_IP)) {
+ return $ip;
+ }
+ }
+ }
+ return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
+}
+
+/**
+ * Genereert een sessie fingerprint op basis van IP en browser/platform
+ */
+function fociz_get_client_fingerprint(): string {
+ $ip = fociz_get_client_ip();
+ $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
+ // Neem IP en User Agent mee voor strikte sessiebinding
+ return hash('sha256', "{$ip}|{$ua}");
+}
+
+/**
+ * Controleert of een IP-adres bekend en geverifieerd is voor een gebruiker
+ */
+function fociz_is_ip_known_for_user(int $userId, string $ip): bool {
+ try {
+ $db = get_db_connection();
+ // Als de gebruiker nog NOOIT een IP heeft opgeslagen, beschouwen we dit als eerste setup
+ $countStmt = $db->prepare("SELECT COUNT(*) FROM user_known_ips WHERE user_id = ?");
+ $countStmt->execute([$userId]);
+ $totalKnown = (int)$countStmt->fetchColumn();
+ if ($totalKnown === 0) {
+ return true; // Eerste inlog ooit
+ }
+
+ $stmt = $db->prepare("SELECT is_verified FROM user_known_ips WHERE user_id = ? AND ip_address = ?");
+ $stmt->execute([$userId, $ip]);
+ $row = $stmt->fetch();
+ return ($row && !empty($row['is_verified']));
+ } catch (Throwable $e) {
+ error_log("fociz_is_ip_known_for_user error: " . $e->getMessage());
+ return true; // Fallback op toegestaan bij databasefout
+ }
+}
+
+/**
+ * Registreert of vernieuwt een IP-adres voor een gebruiker
+ */
+function fociz_register_user_ip(int $userId, string $ip, bool $isVerified = true): void {
+ try {
+ $db = get_db_connection();
+ $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
+ $stmt = $db->prepare("
+ INSERT INTO user_known_ips (user_id, ip_address, user_agent, is_verified, last_seen_at)
+ VALUES (?, ?, ?, ?, NOW())
+ ON DUPLICATE KEY UPDATE
+ user_agent = VALUES(user_agent),
+ is_verified = IF(VALUES(is_verified) = 1, 1, is_verified),
+ last_seen_at = NOW()
+ ");
+ $stmt->execute([$userId, $ip, $ua, $isVerified ? 1 : 0]);
+
+ // Werk ook last_login_ip en last_login_at bij op users tabel
+ $updateUser = $db->prepare("UPDATE users SET last_login_ip = ?, last_login_at = NOW() WHERE id = ?");
+ $updateUser->execute([$ip, $userId]);
+ } catch (Throwable $e) {
+ error_log("fociz_register_user_ip error: " . $e->getMessage());
+ }
+}
+
+/**
+ * Maakt een verificatietoken aan voor een nieuwe locatie/IP en stuurt een e-mail
+ */
+function fociz_send_new_ip_verification(int $userId, string $email, string $name, string $ip): bool {
+ try {
+ $db = get_db_connection();
+ $token = bin2hex(random_bytes(32));
+ $expiresAt = date('Y-m-d H:i:s', time() + 1800); // 30 minuten geldig
+ $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
+
+ $stmt = $db->prepare("
+ INSERT INTO user_known_ips (user_id, ip_address, user_agent, is_verified, verify_token, verify_token_expires_at)
+ VALUES (?, ?, ?, 0, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ user_agent = VALUES(user_agent),
+ verify_token = VALUES(verify_token),
+ verify_token_expires_at = VALUES(verify_token_expires_at)
+ ");
+ $stmt->execute([$userId, $ip, $ua, $token, $expiresAt]);
+
+ // Verificatie e-mail opstellen en verzenden
+ require_once __DIR__ . '/mailer.php';
+ $appUrl = defined('APP_URL') ? APP_URL : 'https://www.fociz.nl';
+ $verifyUrl = "{$appUrl}/auth/verify-ip.php?token={$token}";
+
+ $cleanName = !empty($name) ? htmlspecialchars($name) : 'Collega';
+ $timeStr = date('d-m-Y H:i');
+
+ $contentHtml = "
+
Beste {$cleanName},
+
+ Er is zojuist geprobeerd in te loggen op uw Fociz-account vanaf een onbekende locatie of nieuw IP-adres:
+
+
+
+
+ | Nieuw IP-adres: |
+ {$ip} |
+
+
+ | Tijdstip: |
+ {$timeStr} |
+
+
+
+
+ Bent u dit zelf? Klik dan op onderstaande knop om deze locatie te autoriseren. Hierna kunt u direct veilig inloggen.
+
+ ";
+
+ $html = FocizMailer::renderEmailTemplate([
+ 'title' => 'Nieuwe inloglocatie gedetecteerd',
+ 'preheader' => "Inlogpoging vanaf nieuw IP {$ip} op Fociz",
+ 'badge' => 'LOCATIE BEVEILIGING',
+ 'content_html' => $contentHtml,
+ 'cta_text' => 'Locatie Bevestigen & Toestaan',
+ 'cta_url' => $verifyUrl,
+ 'cta_subtext' => '⏱️ Deze autorisatielink is 30 minuten geldig.',
+ 'security_notice' => 'Was u dit niet? Verander direct uw wachtwoord en neem contact op met uw systeembeheerder.'
+ ]);
+
+ $res = FocizMailer::send($email, $name, "Bevestig nieuwe inloglocatie ({$ip}) — Fociz", $html);
+ return !empty($res['success']);
+ } catch (Throwable $e) {
+ error_log("fociz_send_new_ip_verification error: " . $e->getMessage());
+ return false;
+ }
+}
+
function start_secure_session(): void {
if (session_status() === PHP_SESSION_NONE) {
// Dagsessie: 24 uur (86400s) zodat server-garbage collectors de sessie niet tussentijds wissen
@@ -209,13 +391,26 @@ function get_logged_in_user(): ?array {
$db = get_db_connection();
ensure_remember_token_columns($db);
- // 2. Controleer actieve PHP sessie (mits behorend bij vandaag)
+ $clientFingerprint = fociz_get_client_fingerprint();
+
+ // 2. Controleer actieve PHP sessie (mits behorend bij vandaag én hetzelfde IP/fingerprint)
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
+ // IP-verificatie: als het IP/locatie tussentijds verandert, moet er opnieuw worden ingelogd
+ if (!empty($_SESSION['auth_fingerprint']) && $_SESSION['auth_fingerprint'] !== $clientFingerprint) {
+ $_SESSION = ['session_was_expired' => true, 'ip_changed' => true];
+ clear_day_session_cookie();
+ if (session_status() === PHP_SESSION_ACTIVE) {
+ @session_regenerate_id(true);
+ }
+ return null;
+ }
+
$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 id = ?");
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
if ($user) {
$_SESSION['auth_date'] = $today;
+ $_SESSION['auth_fingerprint'] = $clientFingerprint;
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
$_SESSION['last_activity'] = $now;
return $user;
@@ -233,6 +428,13 @@ function get_logged_in_user(): ?array {
$stmt->execute([$dayToken, $today]);
$user = $stmt->fetch();
if ($user) {
+ $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)) {
+ clear_day_session_cookie();
+ return null;
+ }
+
// Herstel sessie voor vandaag
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
@@ -240,6 +442,7 @@ function get_logged_in_user(): ?array {
$_SESSION['user_role'] = $user['role'];
$_SESSION['is_2fa_verified'] = true;
$_SESSION['auth_date'] = $today;
+ $_SESSION['auth_fingerprint'] = $clientFingerprint;
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
$_SESSION['last_activity'] = $now;
return $user;
@@ -287,8 +490,11 @@ function require_auth(bool $json = false): array {
exit;
}
$wasExpired = !empty($_SESSION['session_was_expired']);
- unset($_SESSION['session_was_expired']);
- if ($wasExpired) {
+ $ipChanged = !empty($_SESSION['ip_changed']);
+ unset($_SESSION['session_was_expired'], $_SESSION['ip_changed']);
+ if ($ipChanged) {
+ header('Location: /auth/login.php?ip_changed=1');
+ } elseif ($wasExpired) {
header('Location: /auth/login.php?expired=1');
} else {
header('Location: /auth/login.php');
diff --git a/includes/cache.php b/includes/cache.php
index 003a620..b458f95 100644
--- a/includes/cache.php
+++ b/includes/cache.php
@@ -137,3 +137,24 @@ function fociz_cache_delete_pattern(string $prefix): bool {
return false;
}
}
+
+/**
+ * Verwijdert alle verlopen items actief uit de cache tabel (AVG/GDPR opschoning)
+ */
+function fociz_cache_cleanup_expired(?PDO $db = null): int {
+ try {
+ $pdo = $db ?: get_db_connection();
+ ensure_app_cache_table($pdo);
+ $stmt = $pdo->prepare("DELETE FROM app_cache WHERE expires_at < ?");
+ $stmt->execute([time()]);
+ return $stmt->rowCount();
+ } catch (Throwable $e) {
+ error_log("fociz_cache_cleanup_expired error: " . $e->getMessage());
+ return 0;
+ }
+}
+
+// Probabilistische automatische GC (5% kans bij elke script execution)
+if (mt_rand(1, 20) === 1) {
+ fociz_cache_cleanup_expired();
+}
diff --git a/includes/integrations/jira.php b/includes/integrations/jira.php
index 1739bce..a71eaba 100644
--- a/includes/integrations/jira.php
+++ b/includes/integrations/jira.php
@@ -866,7 +866,8 @@ function jira_get_dashboard_data(int $userId, bool $forceRefresh = false): array
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
$domain = !empty($settings['jira_domain']) ? $settings['jira_domain'] : jira_get_company_domain();
$email = $settings['jira_email'] ?? fociz_env('JIRA_EMAIL', '');
- $apiToken = $settings['jira_api_token'] ?? fociz_env('JIRA_API_TOKEN', '');
+ $apiTokenRaw = $settings['jira_api_token'] ?? fociz_env('JIRA_API_TOKEN', '');
+ $apiToken = fociz_decrypt($apiTokenRaw);
$accName = $settings['account_name'] ?? 'Atlassian Gebruiker';
if (empty($domain) || empty($email) || empty($apiToken)) {
diff --git a/includes/integrations/microsoft.php b/includes/integrations/microsoft.php
index 1f83a8c..656fc03 100644
--- a/includes/integrations/microsoft.php
+++ b/includes/integrations/microsoft.php
@@ -218,23 +218,25 @@ function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?st
return null;
}
+ $accessToken = fociz_decrypt($integration['access_token']);
+ $refreshToken = fociz_decrypt($integration['refresh_token'] ?? '');
+
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
$expiresAt = (int)($settings['expires_at'] ?? 0);
// Als token nog minstens 4 minuten geldig is én we niet forceren, direct retourneren
if (!$forceRefresh && $expiresAt > (time() + 240)) {
- return $integration['access_token'];
+ return $accessToken;
}
// Token is verlopen of forceRefresh gevraagd; refresh met refresh_token
- $refreshToken = $integration['refresh_token'] ?? '';
$appConfig = microsoft_get_app_config();
$clientId = !empty($settings['client_id']) ? $settings['client_id'] : $appConfig['client_id'];
$clientSecret = !empty($settings['client_secret']) ? $settings['client_secret'] : $appConfig['client_secret'];
$tenantId = !empty($settings['tenant_id']) ? $settings['tenant_id'] : $appConfig['tenant_id'];
if (empty($refreshToken) || empty($clientId) || empty($clientSecret)) {
- return !$forceRefresh ? ($integration['access_token'] ?? null) : null;
+ return !$forceRefresh ? $accessToken : null;
}
$cleanTenant = (!empty($tenantId) && $tenantId !== 'common') ? $tenantId : 'planningsagenda.nl';
@@ -264,15 +266,20 @@ function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?st
SET access_token = ?, refresh_token = ?, settings = ?, last_sync_at = NOW()
WHERE id = ?
");
- $updateStmt->execute([$newAccessToken, $newRefreshToken, json_encode($settings), $integration['id']]);
+ $updateStmt->execute([
+ fociz_encrypt($newAccessToken),
+ fociz_encrypt($newRefreshToken),
+ json_encode($settings),
+ $integration['id']
+ ]);
return $newAccessToken;
}
error_log("Microsoft token refresh mislukt voor user {$userId}: " . ($response['error'] ?? json_encode($response['json'])));
// Fallback: indien het huidige token nog aanwezig is, probeer dit toch te gebruiken
- if (!empty($integration['access_token'])) {
- return $integration['access_token'];
+ if (!empty($accessToken)) {
+ return $accessToken;
}
return null;
}
diff --git a/planner.php b/planner.php
index da7d95d..1b53817 100644
--- a/planner.php
+++ b/planner.php
@@ -1296,7 +1296,7 @@ for ($h = $startHour; $h <= $endHour; $h++) {
-
+
= htmlspecialchars($issue['key'] ?? '') ?>
@@ -1315,7 +1315,7 @@ for ($h = $startHour; $h <= $endHour; $h++) {
class="flex items-center space-x-1 text-[11px] font-semibold text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 bg-indigo-50 dark:bg-indigo-500/10 hover:bg-indigo-100 dark:hover:bg-indigo-500/20 border border-indigo-200 dark:border-indigo-500/20 px-2.5 py-1 rounded-lg transition flex-shrink-0 cursor-pointer"
title="Plan in als dagtaak"
>
-
+
Plan
@@ -1713,6 +1713,146 @@ if ($isAjaxRequest) {
+
+
+
+
+
+
+
+
+
+
Jira Ticket Inplannen
+
—
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Laat leeg voor handmatig inplannen op de tijdlijn
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2638,25 +2778,19 @@ function saveCurrentGridOrderIfChanged() {
const grid = document.getElementById('focizWidgetGrid');
if (!grid) return;
const currentWrappers = Array.from(grid.querySelectorAll('.fociz-widget-wrapper'));
- const currentIndex = activeDraggedWrapper ? currentWrappers.indexOf(activeDraggedWrapper) : -1;
- let hasOrderChanged = false;
+ // Synchroniseer de order in focizWidgets met de actuele DOM-volgorde
currentWrappers.forEach((el, index) => {
const wId = el.getAttribute('data-widget-id');
const item = focizWidgets.find(x => x.id === wId);
if (item) {
- if (item.order !== index + 1) {
- item.order = index + 1;
- hasOrderChanged = true;
- }
+ item.order = index + 1;
}
});
- if (hasOrderChanged || (dragInitialIndex !== -1 && currentIndex !== dragInitialIndex)) {
- // Geruisloos opslaan via AJAX
- persistWidgetLayoutAuto();
- showWidgetToast('Widget verplaatst & indeling opgeslagen', 'success');
- }
+ // Altijd opslaan na een drag (zet ook de volgorde opnieuw neer)
+ persistWidgetLayoutAuto();
+ showWidgetToast('Widget indeling opgeslagen', 'success');
}
// Sluit popup menu's als er buiten wordt geklikt
@@ -2777,7 +2911,7 @@ function filterPlannerJira() {
return `
-
+
${keyEsc}
@@ -2797,7 +2931,7 @@ function filterPlannerJira() {
class="flex items-center space-x-1 text-[11px] font-semibold text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 bg-indigo-50 dark:bg-indigo-500/10 hover:bg-indigo-100 dark:hover:bg-indigo-500/20 border border-indigo-200 dark:border-indigo-500/20 px-2.5 py-1 rounded-lg transition flex-shrink-0 cursor-pointer"
title="Plan in als dagtaak"
>
-
+
Plan
@@ -2809,12 +2943,237 @@ function filterPlannerJira() {
}
}
-async function quickImportJiraByKey(btn, issueKey) {
+// --- Jira Ticket Detail Modal ---
+let _jiraDetailCurrentIssue = null;
+
+function openJiraTicketDetail(issueKey) {
const issue = plannerTicketStore.get(issueKey);
if (!issue) return;
- return quickImportJira(btn, issue.summary || issueKey, issue.key, issue.url || '');
+ _jiraDetailCurrentIssue = issue;
+
+ // Vul header
+ document.getElementById('jiraDetailKey').textContent = issue.key || '—';
+ document.getElementById('jiraDetailSummary').textContent = issue.summary || '—';
+
+ // Status badge
+ const statusEl = document.getElementById('jiraDetailStatusBadge');
+ const status = issue.status || '—';
+ statusEl.textContent = status;
+ statusEl.className = 'text-[10px] px-2 py-0.5 rounded-full font-bold bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400';
+
+ // Priority badge
+ const priorityEl = document.getElementById('jiraDetailPriorityBadge');
+ const prio = issue.priority || '—';
+ priorityEl.textContent = prio;
+ const prioClass = prio === 'Highest' || prio === 'High'
+ ? 'text-[10px] px-2 py-0.5 rounded-full font-bold bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400'
+ : prio === 'Medium'
+ ? 'text-[10px] px-2 py-0.5 rounded-full font-bold bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400'
+ : 'text-[10px] px-2 py-0.5 rounded-full font-bold bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400';
+ priorityEl.className = prioClass;
+
+ // Meta velden
+ document.getElementById('jiraDetailAssignee').textContent = issue.assignee_name || issue.assignee || 'Niet toegewezen';
+ document.getElementById('jiraDetailSp').textContent = issue.story_points ? `${issue.story_points} SP` : '—';
+ document.getElementById('jiraDetailProject').textContent = issue.project_key || issue.project || (issue.key ? issue.key.split('-')[0] : '—');
+ document.getElementById('jiraDetailType').textContent = issue.issue_type || issue.issuetype || 'Story';
+
+ // Beschrijving
+ const descSection = document.getElementById('jiraDetailDescSection');
+ const descEl = document.getElementById('jiraDetailDesc');
+ if (issue.description) {
+ descEl.textContent = issue.description;
+ descSection.classList.remove('hidden');
+ } else {
+ descSection.classList.add('hidden');
+ }
+
+ // Labels
+ const labelsSection = document.getElementById('jiraDetailLabelsSection');
+ const labelsEl = document.getElementById('jiraDetailLabels');
+ if (issue.labels && issue.labels.length > 0) {
+ labelsEl.innerHTML = issue.labels.map(l =>
+ `
${escapeHtml(l)}`
+ ).join('');
+ labelsSection.classList.remove('hidden');
+ } else {
+ labelsSection.classList.add('hidden');
+ }
+
+ // Link naar Jira
+ const linkEl = document.getElementById('jiraDetailOpenLink');
+ if (issue.url) {
+ linkEl.href = issue.url;
+ linkEl.classList.remove('hidden');
+ } else {
+ linkEl.classList.add('hidden');
+ }
+
+ const modal = document.getElementById('jiraTicketDetailModal');
+ modal.classList.remove('hidden');
+ modal.classList.add('flex');
+ if (typeof lucide !== 'undefined') lucide.createIcons();
}
+function closeJiraTicketDetailModal() {
+ const modal = document.getElementById('jiraTicketDetailModal');
+ modal.classList.add('hidden');
+ modal.classList.remove('flex');
+}
+
+function openJiraPlanFromDetail() {
+ closeJiraTicketDetailModal();
+ if (_jiraDetailCurrentIssue) {
+ openJiraPlanModal(_jiraDetailCurrentIssue.key);
+ }
+}
+
+// --- Jira Plan Modal (met tijdsuggestie) ---
+let _jiraPlanCurrentIssue = null;
+
+function openJiraPlanModal(issueKey) {
+ const issue = plannerTicketStore.get(issueKey);
+ if (!issue) return;
+ _jiraPlanCurrentIssue = issue;
+
+ // Vul header
+ document.getElementById('jiraPlanModalKey').textContent = issue.key || '—';
+ document.getElementById('jiraPlanModalSummary').textContent = issue.summary || '—';
+ document.getElementById('jiraPlanModalStatus').textContent = issue.status || '—';
+ document.getElementById('jiraPlanModalPriority').textContent = issue.priority || '—';
+
+ // SP badge
+ const spBadge = document.getElementById('jiraPlanModalSpBadge');
+ if (issue.story_points) {
+ spBadge.textContent = `${issue.story_points} SP`;
+ spBadge.classList.remove('hidden');
+ } else {
+ spBadge.classList.add('hidden');
+ }
+
+ // Standaard duur: 15 min (of op basis van SP: 1 SP = 30 min)
+ let defaultDuration = 15;
+ if (issue.story_points && issue.story_points > 0) {
+ const spMin = Math.round(issue.story_points * 30);
+ defaultDuration = [15, 30, 60, 90].reduce((prev, curr) =>
+ Math.abs(curr - spMin) < Math.abs(prev - spMin) ? curr : prev
+ );
+ }
+ setJiraPlanDuration(defaultDuration);
+
+ // Prioriteit: vertaal Jira priority naar Fociz priority
+ const jiraPrio = (issue.priority || '').toLowerCase();
+ const focizPrio = (jiraPrio === 'highest' || jiraPrio === 'critical') ? 'P1'
+ : (jiraPrio === 'high') ? 'P2'
+ : 'P3';
+ document.getElementById('jiraPlanPriority').value = focizPrio;
+
+ // Slimme starttijd: stel het volgende vrije kwartier voor
+ const now = new Date();
+ const suggestTime = activePlannerDate === now.toISOString().slice(0,10)
+ ? suggestNextFreeSlot()
+ : '09:00';
+ document.getElementById('jiraPlanStartTime').value = suggestTime;
+
+ const modal = document.getElementById('jiraPlanModal');
+ modal.classList.remove('hidden');
+ modal.classList.add('flex');
+ if (typeof lucide !== 'undefined') lucide.createIcons();
+}
+
+function closeJiraPlanModal() {
+ const modal = document.getElementById('jiraPlanModal');
+ modal.classList.add('hidden');
+ modal.classList.remove('flex');
+ _jiraPlanCurrentIssue = null;
+}
+
+function setJiraPlanDuration(minutes) {
+ document.getElementById('jiraPlanDuration').value = minutes;
+ document.querySelectorAll('.jira-plan-dur-btn').forEach(btn => {
+ const dur = parseInt(btn.getAttribute('data-dur'), 10);
+ if (dur === minutes) {
+ btn.className = 'jira-plan-dur-btn px-2 py-1.5 rounded-xl border text-[11px] font-bold transition cursor-pointer bg-indigo-600 border-indigo-600 text-white';
+ } else {
+ btn.className = 'jira-plan-dur-btn px-2 py-1.5 rounded-xl border text-[11px] font-bold transition cursor-pointer bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-700 dark:text-slate-300 hover:border-indigo-400';
+ }
+ });
+}
+
+function suggestNextFreeSlot() {
+ // Zoek het volgende ronde kwartier na het huidige moment
+ const now = new Date();
+ let h = now.getHours();
+ let m = now.getMinutes();
+ // Rond op naar boven naar het volgende kwartier
+ m = Math.ceil((m + 1) / 15) * 15;
+ if (m >= 60) { h += 1; m = 0; }
+ // Begrenzen op werktijd 08:00-18:00
+ if (h < 8) { h = 8; m = 0; }
+ if (h >= 18) { h = 8; m = 0; } // Morgen → val terug op 08:00
+ return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
+}
+
+async function submitJiraPlanModal() {
+ if (!_jiraPlanCurrentIssue) return;
+ const issue = _jiraPlanCurrentIssue;
+
+ const duration = parseInt(document.getElementById('jiraPlanDuration').value, 10) || 15;
+ const startTime = document.getElementById('jiraPlanStartTime').value.trim();
+ const priority = document.getElementById('jiraPlanPriority').value;
+
+ const btn = document.getElementById('btnSubmitJiraPlan');
+ const origHtml = btn.innerHTML;
+ btn.innerHTML = '
Inplannen...';
+ btn.disabled = true;
+
+ try {
+ const payload = {
+ title: `[${issue.key}] ${issue.summary || issue.key}`,
+ priority: priority,
+ source: 'JIRA',
+ external_id: issue.key,
+ external_url: issue.url || '',
+ duration_minutes: duration,
+ date: activePlannerDate
+ };
+ if (startTime) {
+ payload.time_block_start = startTime;
+ // Bereken eindtijd
+ const [hh, mm] = startTime.split(':').map(Number);
+ const endMin = hh * 60 + mm + duration;
+ payload.time_block_end = `${String(Math.floor(endMin / 60)).padStart(2,'0')}:${String(endMin % 60).padStart(2,'0')}`;
+ }
+
+ const res = await fetch('/api/planner.php?action=add_task', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(payload)
+ });
+ const data = await res.json();
+
+ if (data.success) {
+ closeJiraPlanModal();
+ showWidgetToast(`
${escapeHtml(issue.key)} ingepland als dagtaak`, 'success');
+ switchPlannerDate(activePlannerDate, null, false);
+ } else {
+ alert(data.error || 'Kon ticket niet inplannen');
+ btn.innerHTML = origHtml;
+ btn.disabled = false;
+ }
+ } catch(e) {
+ alert('Netwerkfout bij inplannen');
+ btn.innerHTML = origHtml;
+ btn.disabled = false;
+ }
+}
+
+async function quickImportJiraByKey(btn, issueKey) {
+ // Plan knop → open de plan modal (niet direct importeren)
+ openJiraPlanModal(issueKey);
+}
+
+
// --- Direct E-mail Inplannen als Dagtaak ---
function openPlanEmailDirect(mailId, subject, sender, webLink) {
document.getElementById('planEmailId').value = mailId;