Merge pull request 'Dev' (#45) from dev into main
Reviewed-on: #45
This commit was merged in pull request #45.
This commit is contained in:
@@ -28,7 +28,7 @@ try {
|
|||||||
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
|
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
|
||||||
$stmt->execute([$user['id']]);
|
$stmt->execute([$user['id']]);
|
||||||
$saved = json_decode($stmt->fetchColumn() ?: '{}', true);
|
$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($domain)) $domain = $saved['jira_domain'] ?? '';
|
||||||
if (empty($email)) $email = $saved['jira_email'] ?? '';
|
if (empty($email)) $email = $saved['jira_email'] ?? '';
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ try {
|
|||||||
$settings = json_encode([
|
$settings = json_encode([
|
||||||
'jira_domain' => jira_normalize_domain($domain),
|
'jira_domain' => jira_normalize_domain($domain),
|
||||||
'jira_email' => $email,
|
'jira_email' => $email,
|
||||||
'jira_api_token' => $apiToken,
|
'jira_api_token' => fociz_encrypt($apiToken),
|
||||||
'account_name' => $jiraUser['displayName'] ?? '',
|
'account_name' => $jiraUser['displayName'] ?? '',
|
||||||
'account_id' => $jiraUser['accountId'] ?? '',
|
'account_id' => $jiraUser['accountId'] ?? '',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -137,7 +137,12 @@ $saveStmt = $db->prepare("
|
|||||||
settings = VALUES(settings),
|
settings = VALUES(settings),
|
||||||
last_sync_at = NOW()
|
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
|
// 8. Succesvol terugkeren naar de gewenste pagina of het Persoonlijk Profiel
|
||||||
$returnTo = $_SESSION['ms_oauth_return_to'] ?? '/profile.php';
|
$returnTo = $_SESSION['ms_oauth_return_to'] ?? '/profile.php';
|
||||||
|
|||||||
+23
-6
@@ -22,13 +22,19 @@ if ($currentUser) {
|
|||||||
$error = null;
|
$error = null;
|
||||||
$infoMessage = null;
|
$infoMessage = null;
|
||||||
if (isset($_GET['expired'])) {
|
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;
|
$step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
|
||||||
|
|
||||||
// Terug naar stap 1
|
// Terug naar stap 1
|
||||||
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
|
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');
|
header('Location: /auth/login.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -50,11 +56,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$user = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
if ($user && password_verify($password, $user['password_hash'])) {
|
if ($user && password_verify($password, $user['password_hash'])) {
|
||||||
|
// 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
|
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
|
||||||
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||||
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||||
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
||||||
$step = 2;
|
$step = 2;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$error = "Onjuist e-mailadres of wachtwoord.";
|
$error = "Onjuist e-mailadres of wachtwoord.";
|
||||||
}
|
}
|
||||||
@@ -105,12 +121,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if ($isValid) {
|
if ($isValid) {
|
||||||
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
|
// 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_id'] = $user['id'];
|
||||||
$_SESSION['user_name'] = $user['name'];
|
$_SESSION['user_name'] = $user['name'];
|
||||||
$_SESSION['user_email'] = $user['email'];
|
$_SESSION['user_email'] = $user['email'];
|
||||||
$_SESSION['user_role'] = $user['role'];
|
$_SESSION['user_role'] = $user['role'];
|
||||||
$_SESSION['is_2fa_verified'] = true;
|
$_SESSION['is_2fa_verified'] = true;
|
||||||
$_SESSION['auth_date'] = date('Y-m-d');
|
$_SESSION['auth_date'] = date('Y-m-d');
|
||||||
|
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||||
$_SESSION['last_activity'] = time();
|
$_SESSION['last_activity'] = time();
|
||||||
|
|
||||||
@@ -249,10 +269,7 @@ $pageTitle = "Fociz — Inloggen";
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input type="text" name="totp_code" required autofocus maxlength="9" placeholder="000 000" class="w-full text-center tracking-[0.4em] font-mono text-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
|
<input type="text" name="totp_code" required autofocus maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="000 000" class="w-full text-center tracking-[0.4em] font-mono text-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
|
||||||
<span class="text-[11px] text-slate-500 dark:text-slate-500 text-center block mt-1">
|
|
||||||
Tip: U kunt ook een 8-cijferige herstelcode invoeren.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Fociz Nieuwe Inloglocatie / IP Verificatie
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
start_secure_session();
|
||||||
|
|
||||||
|
$token = trim($_GET['token'] ?? '');
|
||||||
|
$db = get_db_connection();
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
$success = false;
|
||||||
|
$userEmail = '';
|
||||||
|
$ipAddress = '';
|
||||||
|
|
||||||
|
if (empty($token) || !preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||||
|
$error = "Ongeldige of ontbrekende verificatielink.";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT kip.*, u.email, u.name
|
||||||
|
FROM user_known_ips kip
|
||||||
|
JOIN users u ON kip.user_id = u.id
|
||||||
|
WHERE kip.verify_token = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$token]);
|
||||||
|
$record = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
$error = "Deze verificatielink is niet gevonden of al gebruikt.";
|
||||||
|
} elseif (!empty($record['verify_token_expires_at']) && strtotime($record['verify_token_expires_at']) < time()) {
|
||||||
|
$error = "Deze verificatielink is verlopen (geldigheidsduur was 30 minuten). Probeer opnieuw in te loggen om een nieuwe link te ontvangen.";
|
||||||
|
} else {
|
||||||
|
// Verifieer het IP-adres
|
||||||
|
$updateStmt = $db->prepare("
|
||||||
|
UPDATE user_known_ips
|
||||||
|
SET is_verified = 1, verify_token = NULL, verify_token_expires_at = NULL, last_seen_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$updateStmt->execute([$record['id']]);
|
||||||
|
|
||||||
|
$success = true;
|
||||||
|
$userEmail = $record['email'];
|
||||||
|
$ipAddress = $record['ip_address'];
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("verify-ip.php error: " . $e->getMessage());
|
||||||
|
$error = "Er is een databasefout opgetreden bij het verifiëren.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageTitle = "Fociz — Locatie Verificatie";
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="nl" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||||
|
<link rel="alternate icon" href="/favicon.ico">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
indigo: { 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-100 dark:bg-[#0c0f17] text-slate-800 dark:text-slate-100 min-h-screen flex items-center justify-center p-4 transition-colors duration-200">
|
||||||
|
|
||||||
|
<div class="w-full max-w-md bg-white dark:bg-[#121622] rounded-2xl border border-slate-200/80 dark:border-slate-800/80 shadow-2xl p-8 space-y-6">
|
||||||
|
|
||||||
|
<!-- Logo & Header -->
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-500/10 border border-indigo-500/20 text-indigo-500 mb-2">
|
||||||
|
<i data-lucide="shield-check" class="w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-bold text-slate-900 dark:text-white">Locatie Verificatie</h1>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400">Beveiligde inlogtoegang voor Fociz</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<div class="p-4 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-800 dark:text-emerald-400 text-sm space-y-2 text-center">
|
||||||
|
<div class="flex items-center justify-center space-x-2 font-bold text-emerald-700 dark:text-emerald-300">
|
||||||
|
<i data-lucide="check-circle-2" class="w-5 h-5"></i>
|
||||||
|
<span>Locatie Succesvol Geverifieerd!</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs">
|
||||||
|
Het IP-adres <strong class="font-mono"><?= htmlspecialchars($ipAddress) ?></strong> is nu geautoriseerd voor uw account (<?= htmlspecialchars($userEmail) ?>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/auth/login.php?ip_verified=1" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||||
|
<span>Direct Inloggen</span>
|
||||||
|
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 text-rose-800 dark:text-rose-400 text-sm space-y-2 text-center">
|
||||||
|
<div class="flex items-center justify-center space-x-2 font-bold text-rose-700 dark:text-rose-300">
|
||||||
|
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||||
|
<span>Verificatie Mislukt</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs leading-relaxed">
|
||||||
|
<?= htmlspecialchars($error ?? 'Onbekende fout.') ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/auth/login.php" class="w-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold py-2.5 px-4 rounded-xl transition flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||||
|
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||||
|
<span>Terug naar Inloggen</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
if (typeof lucide !== 'undefined') {
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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",
|
"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
|
// 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",
|
"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) {
|
foreach ($colUpgrades as $sql) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ CREATE TABLE IF NOT EXISTS `users` (
|
|||||||
`fociz_widget_layout` TEXT NULL,
|
`fociz_widget_layout` TEXT NULL,
|
||||||
`last_mail_check_at` DATETIME NULL,
|
`last_mail_check_at` DATETIME NULL,
|
||||||
`last_jira_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,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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_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
|
CONSTRAINT `fk_subtasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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;
|
||||||
|
|||||||
+209
-3
@@ -33,6 +33,188 @@ function is_https_request(): bool {
|
|||||||
return false;
|
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 = "
|
||||||
|
<p style='margin: 0 0 14px 0;'>Beste <strong>{$cleanName}</strong>,</p>
|
||||||
|
<p style='margin: 0 0 14px 0;'>
|
||||||
|
Er is zojuist geprobeerd in te loggen op uw Fociz-account vanaf een <strong>onbekende locatie of nieuw IP-adres</strong>:
|
||||||
|
</p>
|
||||||
|
<div style='background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.25); border-radius: 12px; padding: 14px 18px; margin: 18px 0;'>
|
||||||
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
||||||
|
<tr>
|
||||||
|
<td style='font-size: 13px; color: #94a3b8;'>Nieuw IP-adres:</td>
|
||||||
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #f87171; font-family: monospace;'>{$ip}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Tijdstip:</td>
|
||||||
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0; padding-top: 6px;'>{$timeStr}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style='margin: 0 0 14px 0;'>
|
||||||
|
Bent u dit zelf? Klik dan op onderstaande knop om deze locatie te autoriseren. Hierna kunt u direct veilig inloggen.
|
||||||
|
</p>
|
||||||
|
";
|
||||||
|
|
||||||
|
$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 {
|
function start_secure_session(): void {
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
// Dagsessie: 24 uur (86400s) zodat server-garbage collectors de sessie niet tussentijds wissen
|
// 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();
|
$db = get_db_connection();
|
||||||
ensure_remember_token_columns($db);
|
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'])) {
|
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 = $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']]);
|
$stmt->execute([$_SESSION['user_id']]);
|
||||||
$user = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$_SESSION['auth_date'] = $today;
|
$_SESSION['auth_date'] = $today;
|
||||||
|
$_SESSION['auth_fingerprint'] = $clientFingerprint;
|
||||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||||
$_SESSION['last_activity'] = $now;
|
$_SESSION['last_activity'] = $now;
|
||||||
return $user;
|
return $user;
|
||||||
@@ -233,6 +428,13 @@ function get_logged_in_user(): ?array {
|
|||||||
$stmt->execute([$dayToken, $today]);
|
$stmt->execute([$dayToken, $today]);
|
||||||
$user = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
if ($user) {
|
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
|
// Herstel sessie voor vandaag
|
||||||
$_SESSION['user_id'] = $user['id'];
|
$_SESSION['user_id'] = $user['id'];
|
||||||
$_SESSION['user_name'] = $user['name'];
|
$_SESSION['user_name'] = $user['name'];
|
||||||
@@ -240,6 +442,7 @@ function get_logged_in_user(): ?array {
|
|||||||
$_SESSION['user_role'] = $user['role'];
|
$_SESSION['user_role'] = $user['role'];
|
||||||
$_SESSION['is_2fa_verified'] = true;
|
$_SESSION['is_2fa_verified'] = true;
|
||||||
$_SESSION['auth_date'] = $today;
|
$_SESSION['auth_date'] = $today;
|
||||||
|
$_SESSION['auth_fingerprint'] = $clientFingerprint;
|
||||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||||
$_SESSION['last_activity'] = $now;
|
$_SESSION['last_activity'] = $now;
|
||||||
return $user;
|
return $user;
|
||||||
@@ -287,8 +490,11 @@ function require_auth(bool $json = false): array {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$wasExpired = !empty($_SESSION['session_was_expired']);
|
$wasExpired = !empty($_SESSION['session_was_expired']);
|
||||||
unset($_SESSION['session_was_expired']);
|
$ipChanged = !empty($_SESSION['ip_changed']);
|
||||||
if ($wasExpired) {
|
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');
|
header('Location: /auth/login.php?expired=1');
|
||||||
} else {
|
} else {
|
||||||
header('Location: /auth/login.php');
|
header('Location: /auth/login.php');
|
||||||
|
|||||||
@@ -137,3 +137,24 @@ function fociz_cache_delete_pattern(string $prefix): bool {
|
|||||||
return false;
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -866,7 +866,8 @@ function jira_get_dashboard_data(int $userId, bool $forceRefresh = false): array
|
|||||||
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
|
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
|
||||||
$domain = !empty($settings['jira_domain']) ? $settings['jira_domain'] : jira_get_company_domain();
|
$domain = !empty($settings['jira_domain']) ? $settings['jira_domain'] : jira_get_company_domain();
|
||||||
$email = $settings['jira_email'] ?? fociz_env('JIRA_EMAIL', '');
|
$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';
|
$accName = $settings['account_name'] ?? 'Atlassian Gebruiker';
|
||||||
|
|
||||||
if (empty($domain) || empty($email) || empty($apiToken)) {
|
if (empty($domain) || empty($email) || empty($apiToken)) {
|
||||||
|
|||||||
@@ -218,23 +218,25 @@ function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?st
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$accessToken = fociz_decrypt($integration['access_token']);
|
||||||
|
$refreshToken = fociz_decrypt($integration['refresh_token'] ?? '');
|
||||||
|
|
||||||
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
|
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
|
||||||
$expiresAt = (int)($settings['expires_at'] ?? 0);
|
$expiresAt = (int)($settings['expires_at'] ?? 0);
|
||||||
|
|
||||||
// Als token nog minstens 4 minuten geldig is én we niet forceren, direct retourneren
|
// Als token nog minstens 4 minuten geldig is én we niet forceren, direct retourneren
|
||||||
if (!$forceRefresh && $expiresAt > (time() + 240)) {
|
if (!$forceRefresh && $expiresAt > (time() + 240)) {
|
||||||
return $integration['access_token'];
|
return $accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token is verlopen of forceRefresh gevraagd; refresh met refresh_token
|
// Token is verlopen of forceRefresh gevraagd; refresh met refresh_token
|
||||||
$refreshToken = $integration['refresh_token'] ?? '';
|
|
||||||
$appConfig = microsoft_get_app_config();
|
$appConfig = microsoft_get_app_config();
|
||||||
$clientId = !empty($settings['client_id']) ? $settings['client_id'] : $appConfig['client_id'];
|
$clientId = !empty($settings['client_id']) ? $settings['client_id'] : $appConfig['client_id'];
|
||||||
$clientSecret = !empty($settings['client_secret']) ? $settings['client_secret'] : $appConfig['client_secret'];
|
$clientSecret = !empty($settings['client_secret']) ? $settings['client_secret'] : $appConfig['client_secret'];
|
||||||
$tenantId = !empty($settings['tenant_id']) ? $settings['tenant_id'] : $appConfig['tenant_id'];
|
$tenantId = !empty($settings['tenant_id']) ? $settings['tenant_id'] : $appConfig['tenant_id'];
|
||||||
|
|
||||||
if (empty($refreshToken) || empty($clientId) || empty($clientSecret)) {
|
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';
|
$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()
|
SET access_token = ?, refresh_token = ?, settings = ?, last_sync_at = NOW()
|
||||||
WHERE id = ?
|
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;
|
return $newAccessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_log("Microsoft token refresh mislukt voor user {$userId}: " . ($response['error'] ?? json_encode($response['json'])));
|
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
|
// Fallback: indien het huidige token nog aanwezig is, probeer dit toch te gebruiken
|
||||||
if (!empty($integration['access_token'])) {
|
if (!empty($accessToken)) {
|
||||||
return $integration['access_token'];
|
return $accessToken;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+374
-15
@@ -1296,7 +1296,7 @@ for ($h = $startHour; $h <= $endHour; $h++) {
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach (array_slice($jiraAllIssues, 0, 8) as $issue): ?>
|
<?php foreach (array_slice($jiraAllIssues, 0, 8) as $issue): ?>
|
||||||
<div class="fociz-card-interactive flex items-center justify-between p-2.5 rounded-xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200/80 dark:border-slate-800/80 hover:border-indigo-300 dark:hover:border-indigo-600/50 transition shadow-2xs">
|
<div class="fociz-card-interactive flex items-center justify-between p-2.5 rounded-xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200/80 dark:border-slate-800/80 hover:border-indigo-300 dark:hover:border-indigo-600/50 transition shadow-2xs">
|
||||||
<div class="truncate mr-2 min-w-0 flex-1">
|
<div class="truncate mr-2 min-w-0 flex-1 cursor-pointer" onclick="openJiraTicketDetail('<?= htmlspecialchars($issue['key'] ?? '') ?>')" title="Klik voor ticketdetails">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span class="font-mono text-[10px] font-bold text-sky-700 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-1.5 py-0.5 rounded">
|
<span class="font-mono text-[10px] font-bold text-sky-700 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-1.5 py-0.5 rounded">
|
||||||
<?= htmlspecialchars($issue['key'] ?? '') ?>
|
<?= 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"
|
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"
|
title="Plan in als dagtaak"
|
||||||
>
|
>
|
||||||
<i data-lucide="plus" class="w-3 h-3"></i>
|
<i data-lucide="calendar-plus" class="w-3 h-3"></i>
|
||||||
<span>Plan</span>
|
<span>Plan</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1713,6 +1713,146 @@ if ($isAjaxRequest) {
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Modal: Jira Ticket Inplannen (met tijdsuggestie) -->
|
||||||
|
<div id="jiraPlanModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||||
|
<div class="fociz-card p-6 w-full max-w-md shadow-2xl space-y-4">
|
||||||
|
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||||||
|
<div class="flex items-center space-x-2.5">
|
||||||
|
<div class="w-8 h-8 rounded-xl bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 text-sky-600 dark:text-sky-400 flex items-center justify-center">
|
||||||
|
<i data-lucide="calendar-plus" class="w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Jira Ticket Inplannen</h3>
|
||||||
|
<p class="text-[10px] text-slate-500 dark:text-slate-400" id="jiraPlanModalKey">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeJiraPlanModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 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>
|
||||||
|
|
||||||
|
<!-- Ticket samenvatting -->
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-slate-800 p-3">
|
||||||
|
<p class="text-xs font-semibold text-slate-800 dark:text-slate-200 leading-snug" id="jiraPlanModalSummary">—</p>
|
||||||
|
<div class="flex items-center space-x-2 mt-1.5">
|
||||||
|
<span class="text-[10px] text-slate-500" id="jiraPlanModalStatus">—</span>
|
||||||
|
<span class="text-slate-300 dark:text-slate-600">•</span>
|
||||||
|
<span class="text-[10px] text-slate-500" id="jiraPlanModalPriority">—</span>
|
||||||
|
<span id="jiraPlanModalSpBadge" class="hidden px-1.5 py-0.5 rounded bg-violet-50 dark:bg-violet-500/10 border border-violet-200 dark:border-violet-500/20 text-violet-700 dark:text-violet-400 font-mono text-[9px] font-bold"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tijdsuggestie -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-bold text-slate-700 dark:text-slate-300 mb-1.5">Starttijd (suggestie)</label>
|
||||||
|
<input type="time" id="jiraPlanStartTime" class="w-full bg-white dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-sm text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
|
<p class="text-[10px] text-slate-400 mt-1">Laat leeg voor handmatig inplannen op de tijdlijn</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-bold text-slate-700 dark:text-slate-300 mb-1.5">Duur</label>
|
||||||
|
<div class="grid grid-cols-4 gap-2">
|
||||||
|
<button type="button" onclick="setJiraPlanDuration(15)" class="jira-plan-dur-btn px-2 py-1.5 rounded-xl border text-[11px] font-bold transition cursor-pointer" data-dur="15">15m</button>
|
||||||
|
<button type="button" onclick="setJiraPlanDuration(30)" class="jira-plan-dur-btn px-2 py-1.5 rounded-xl border text-[11px] font-bold transition cursor-pointer" data-dur="30">30m</button>
|
||||||
|
<button type="button" onclick="setJiraPlanDuration(60)" class="jira-plan-dur-btn px-2 py-1.5 rounded-xl border text-[11px] font-bold transition cursor-pointer" data-dur="60">1u</button>
|
||||||
|
<button type="button" onclick="setJiraPlanDuration(90)" class="jira-plan-dur-btn px-2 py-1.5 rounded-xl border text-[11px] font-bold transition cursor-pointer" data-dur="90">1,5u</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="jiraPlanDuration" value="15">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-bold text-slate-700 dark:text-slate-300 mb-1.5">Prioriteit</label>
|
||||||
|
<select id="jiraPlanPriority" class="w-full bg-white dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-sm text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
|
<option value="P1">P1 — Urgent</option>
|
||||||
|
<option value="P2">P2 — Hoog</option>
|
||||||
|
<option value="P3" selected>P3 — Normaal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-3 pt-1">
|
||||||
|
<button type="button" onclick="closeJiraPlanModal()" class="flex-1 px-4 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold text-sm transition cursor-pointer">
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btnSubmitJiraPlan" onclick="submitJiraPlanModal()" class="flex-1 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-sm transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||||
|
<i data-lucide="calendar-plus" class="w-4 h-4"></i>
|
||||||
|
<span>Inplannen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Jira Ticket Detail (popup bij klikken op ticket) -->
|
||||||
|
<div id="jiraTicketDetailModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||||
|
<div class="fociz-card p-0 w-full max-w-lg shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between p-5 border-b border-slate-100 dark:border-slate-800/80 flex-shrink-0">
|
||||||
|
<div class="space-y-1 min-w-0 flex-1 mr-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="font-mono text-[11px] font-bold text-sky-700 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-0.5 rounded flex-shrink-0" id="jiraDetailKey">—</span>
|
||||||
|
<span class="text-[10px] px-2 py-0.5 rounded-full font-bold" id="jiraDetailStatusBadge">—</span>
|
||||||
|
<span class="text-[10px] px-2 py-0.5 rounded-full font-bold" id="jiraDetailPriorityBadge">—</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-sm font-bold text-slate-900 dark:text-white leading-snug" id="jiraDetailSummary">—</h3>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeJiraTicketDetailModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer flex-shrink-0">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body: scrollable -->
|
||||||
|
<div class="overflow-y-auto flex-1 p-5 space-y-4">
|
||||||
|
<!-- Meta info -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-xl p-3 border border-slate-200 dark:border-slate-800">
|
||||||
|
<p class="text-[10px] text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Aangemeld bij</p>
|
||||||
|
<p class="text-xs font-bold text-slate-900 dark:text-white" id="jiraDetailAssignee">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-xl p-3 border border-slate-200 dark:border-slate-800">
|
||||||
|
<p class="text-[10px] text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Story Points</p>
|
||||||
|
<p class="text-xs font-bold text-slate-900 dark:text-white" id="jiraDetailSp">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-xl p-3 border border-slate-200 dark:border-slate-800">
|
||||||
|
<p class="text-[10px] text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Project</p>
|
||||||
|
<p class="text-xs font-bold text-slate-900 dark:text-white" id="jiraDetailProject">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-xl p-3 border border-slate-200 dark:border-slate-800">
|
||||||
|
<p class="text-[10px] text-slate-500 font-semibold uppercase tracking-wide mb-0.5">Type</p>
|
||||||
|
<p class="text-xs font-bold text-slate-900 dark:text-white" id="jiraDetailType">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beschrijving -->
|
||||||
|
<div id="jiraDetailDescSection" class="hidden">
|
||||||
|
<p class="text-[10px] text-slate-500 font-semibold uppercase tracking-wide mb-1.5">Beschrijving</p>
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-xl p-3 border border-slate-200 dark:border-slate-800 text-xs text-slate-700 dark:text-slate-300 leading-relaxed whitespace-pre-wrap max-h-40 overflow-y-auto" id="jiraDetailDesc"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Labels -->
|
||||||
|
<div id="jiraDetailLabelsSection" class="hidden">
|
||||||
|
<p class="text-[10px] text-slate-500 font-semibold uppercase tracking-wide mb-1.5">Labels</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5" id="jiraDetailLabels"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-between p-4 border-t border-slate-100 dark:border-slate-800/80 flex-shrink-0 gap-3">
|
||||||
|
<a href="#" target="_blank" id="jiraDetailOpenLink" class="flex items-center space-x-1.5 text-xs font-semibold text-sky-600 dark:text-sky-400 hover:underline cursor-pointer">
|
||||||
|
<i data-lucide="external-link" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Openen in Jira</span>
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button type="button" onclick="closeJiraTicketDetailModal()" class="px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold text-xs transition cursor-pointer">
|
||||||
|
Sluiten
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="openJiraPlanFromDetail()" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs transition cursor-pointer">
|
||||||
|
<i data-lucide="calendar-plus" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Inplannen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal: Taak & Tijdblok Bewerken (Vrij invullen van tijden & duur) -->
|
<!-- Modal: Taak & Tijdblok Bewerken (Vrij invullen van tijden & duur) -->
|
||||||
<div id="editTaskModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
<div id="editTaskModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||||
<div class="fociz-card p-6 w-full max-w-lg shadow-2xl space-y-4">
|
<div class="fociz-card p-6 w-full max-w-lg shadow-2xl space-y-4">
|
||||||
@@ -2638,25 +2778,19 @@ function saveCurrentGridOrderIfChanged() {
|
|||||||
const grid = document.getElementById('focizWidgetGrid');
|
const grid = document.getElementById('focizWidgetGrid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
const currentWrappers = Array.from(grid.querySelectorAll('.fociz-widget-wrapper'));
|
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) => {
|
currentWrappers.forEach((el, index) => {
|
||||||
const wId = el.getAttribute('data-widget-id');
|
const wId = el.getAttribute('data-widget-id');
|
||||||
const item = focizWidgets.find(x => x.id === wId);
|
const item = focizWidgets.find(x => x.id === wId);
|
||||||
if (item) {
|
if (item) {
|
||||||
if (item.order !== index + 1) {
|
|
||||||
item.order = index + 1;
|
item.order = index + 1;
|
||||||
hasOrderChanged = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasOrderChanged || (dragInitialIndex !== -1 && currentIndex !== dragInitialIndex)) {
|
// Altijd opslaan na een drag (zet ook de volgorde opnieuw neer)
|
||||||
// Geruisloos opslaan via AJAX
|
|
||||||
persistWidgetLayoutAuto();
|
persistWidgetLayoutAuto();
|
||||||
showWidgetToast('Widget verplaatst & indeling opgeslagen', 'success');
|
showWidgetToast('Widget indeling opgeslagen', 'success');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sluit popup menu's als er buiten wordt geklikt
|
// Sluit popup menu's als er buiten wordt geklikt
|
||||||
@@ -2777,7 +2911,7 @@ function filterPlannerJira() {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="fociz-card-interactive flex items-center justify-between p-2.5 rounded-xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200/80 dark:border-slate-800/80 hover:border-indigo-300 dark:hover:border-indigo-600/50 transition shadow-2xs">
|
<div class="fociz-card-interactive flex items-center justify-between p-2.5 rounded-xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200/80 dark:border-slate-800/80 hover:border-indigo-300 dark:hover:border-indigo-600/50 transition shadow-2xs">
|
||||||
<div class="truncate mr-2 min-w-0 flex-1">
|
<div class="truncate mr-2 min-w-0 flex-1 cursor-pointer" onclick="openJiraTicketDetail('${keyEsc}')" title="Klik voor ticketdetails">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span class="font-mono text-[10px] font-bold text-sky-700 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-1.5 py-0.5 rounded">
|
<span class="font-mono text-[10px] font-bold text-sky-700 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-1.5 py-0.5 rounded">
|
||||||
${keyEsc}
|
${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"
|
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"
|
title="Plan in als dagtaak"
|
||||||
>
|
>
|
||||||
<i data-lucide="plus" class="w-3 h-3"></i>
|
<i data-lucide="calendar-plus" class="w-3 h-3"></i>
|
||||||
<span>Plan</span>
|
<span>Plan</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -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);
|
const issue = plannerTicketStore.get(issueKey);
|
||||||
if (!issue) return;
|
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 =>
|
||||||
|
`<span class="px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-[10px] font-semibold border border-slate-200 dark:border-slate-700">${escapeHtml(l)}</span>`
|
||||||
|
).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 = '<span class="text-xs font-bold animate-pulse">Inplannen...</span>';
|
||||||
|
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(`<strong>${escapeHtml(issue.key)}</strong> 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 ---
|
// --- Direct E-mail Inplannen als Dagtaak ---
|
||||||
function openPlanEmailDirect(mailId, subject, sender, webLink) {
|
function openPlanEmailDirect(mailId, subject, sender, webLink) {
|
||||||
document.getElementById('planEmailId').value = mailId;
|
document.getElementById('planEmailId').value = mailId;
|
||||||
|
|||||||
Reference in New Issue
Block a user