Add roadmap management, service worker, and user invitation features

- Implemented roadmap.php for managing roadmap items with filtering options by quarter and status.
- Added a service worker (sw.js) to handle push notifications and click events for calendar appointments.
- Created users.php for admin to manage team members and invitations, including user creation and deletion functionalities.
This commit is contained in:
2026-09-09 15:54:24 +02:00
commit 9262b09c4d
41 changed files with 10579 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
<?php
/**
* Fociz AI Co-Pilot Engine (Release v1.5)
* Beheert intelligente assistentie voor Brainstorming en Dagplanning.
*/
require_once __DIR__ . '/http.php';
require_once __DIR__ . '/settings.php';
/**
* Voert een gestructureerde chat completion uit met de geconfigureerde AI provider
*
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int]
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string]
*/
function fociz_ai_chat(array $messages, array $options = []): array {
$aiSettings = get_ai_settings();
if (empty($aiSettings['ai_api_key'])) {
return [
'success' => false,
'error' => 'Geen AI API-sleutel geconfigureerd. Stel deze in via Systeembeheer (admin.php).',
];
}
$apiKey = $aiSettings['ai_api_key'];
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gpt-4o-mini';
$temperature = $options['temperature'] ?? 0.3;
$maxTokens = $options['max_tokens'] ?? 1500;
$jsonMode = !empty($options['json_mode']);
$payload = [
'model' => $model,
'messages' => $messages,
'temperature' => $temperature,
'max_tokens' => $maxTokens,
];
if ($jsonMode) {
$payload['response_format'] = ['type' => 'json_object'];
}
$response = fociz_http_request('https://api.openai.com/v1/chat/completions', [
'method' => 'POST',
'headers' => [
'Authorization' => "Bearer {$apiKey}",
'Content-Type' => 'application/json',
],
'body' => $payload,
'timeout' => 35,
]);
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "AI aanroep mislukt (HTTP {$response['status']})");
error_log("fociz_ai_chat fout: {$errMsg}");
return [
'success' => false,
'error' => $errMsg,
];
}
$rawContent = trim($response['json']['choices'][0]['message']['content']);
$parsedJson = null;
if ($jsonMode) {
$parsedJson = json_decode($rawContent, true);
}
return [
'success' => true,
'content' => $rawContent,
'json' => $parsedJson,
'model' => $response['json']['model'] ?? $model,
];
}
/**
* Categoriseert sticky notes automatisch in:
* - 'Ideeën'
* - 'Knelpunten'
* - 'Oplossingen'
* - 'Quick Wins'
*/
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards): array {
if (empty($cards)) {
return ['success' => true, 'categorized' => []];
}
$cardItems = [];
foreach ($cards as $c) {
$cardItems[] = [
'id' => $c['id'],
'content' => $c['content'],
'category' => $c['category'] ?? 'Ideeën',
];
}
$systemPrompt = "Je bent een deskundige agile facilitator en brainstorm co-pilot voor software- en productontwikkeling.\n"
. "Je ontvangt een lijst met brainstorm kaartjes voor de sessie: '{$sessionTitle}' (Thema: '{$theme}').\n"
. "Je taak is om elk kaartje in te delen in exact één van deze 4 categorieën:\n"
. "1. 'Ideeën' (nieuwe concepten, functies, wensen)\n"
. "2. 'Knelpunten' (problemen, blokkades, risico's, frustraties)\n"
. "3. 'Oplossingen' (concrete antwoorden op knelpunten, architectuurkeuzes)\n"
. "4. 'Quick Wins' (kleine, makkelijk uitvoerbare verbeteringen met hoge impact)\n\n"
. "Retourneer uitsluitend een JSON-object met het veld 'categorized' dat een array is van objecten met 'id' en 'category'.";
$userPrompt = "Hier zijn de kaartjes:\n" . json_encode($cardItems, JSON_UNESCAPED_UNICODE);
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['json_mode' => true]);
if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren'];
}
return ['success' => true, 'categorized' => $aiRes['json']['categorized']];
}
/**
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
*/
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4): array {
$systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n"
. "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n"
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een gele post-it staan).\n"
. "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is.";
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën."],
], ['json_mode' => true]);
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
}
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
}
/**
* Zet brainstorm kaartjes om in een scherp Roadmap Item
*/
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = ''): array {
$systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n"
. "Retourneer een JSON-object met:\n"
. "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n"
. "- 'description': Een duidelijke beschrijving van wat er gebouwd moet worden inclusief gebruikerswaarde en 2-3 acceptatiecriteria\n"
. "- 'priority': 'HIGH', 'MEDIUM' of 'LOW'\n"
. "- 'type': 'FEATURE', 'IMPROVEMENT' of 'TECH_DEBT'\n"
. "- 'quarter': 'Q3' of 'Q4'";
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"],
], ['json_mode' => true]);
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren'];
}
return ['success' => true, 'item' => $aiRes['json']];
}
/**
* Berekent een slimme dagindeling voor open taken rondom bestaande afspraken
*/
function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySettings): array {
$startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0];
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:30')[0];
$lunchStart = $workdaySettings['lunch_start'] ?? '12:30';
$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00';
$systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n"
. "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n"
. "Regels:\n"
. "1. Plan GEEN taken tijdens bestaande afspraken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
. "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n"
. "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n"
. "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}.";
$userPrompt = "Bestaande afspraken:\n" . json_encode($events, JSON_UNESCAPED_UNICODE) . "\n\n"
. "In te plannen taken:\n" . json_encode($tasks, JSON_UNESCAPED_UNICODE);
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['json_mode' => true]);
if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen'];
}
return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']];
}
+459
View File
@@ -0,0 +1,459 @@
<?php
/**
* Authenticatie & Autorisatie Guard voor Fociz
*/
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/totp.php';
/**
* Bepaalt of de huidige verbinding HTTPS gebruikt (inclusief reverse proxies / OpenResty / Docker)
*/
function is_https_request(): bool {
if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') {
return true;
}
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
return true;
}
if (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && strtolower($_SERVER['HTTP_X_FORWARDED_SSL']) === 'on') {
return true;
}
if (isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443) {
return true;
}
if (defined('APP_URL') && str_starts_with(APP_URL, 'https://')) {
return true;
}
return false;
}
function start_secure_session(): void {
if (session_status() === PHP_SESSION_NONE) {
// Zorg voor een ruime sessieduur (30 dagen)
ini_set('session.gc_maxlifetime', '2592000');
ini_set('session.cookie_lifetime', '2592000');
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
$isSecure = is_https_request();
session_set_cookie_params([
'lifetime' => 30 * 24 * 60 * 60, // 30 dagen
'path' => '/',
'domain' => '',
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Lax'
]);
session_start();
}
}
/**
* Controleert of er al een beheerder bestaat in MariaDB
*/
function has_admin_user(): bool {
try {
$db = get_db_connection();
$stmt = $db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'");
return ((int)$stmt->fetchColumn()) > 0;
} catch (Throwable $e) {
return false;
}
}
/**
* Garandeert dat de kolommen voor persistent sessiebeheer aanwezig zijn in MariaDB
*/
function ensure_remember_token_columns(PDO $db): void {
static $columnsChecked = false;
if ($columnsChecked) return;
$columnsChecked = true;
try {
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'remember_token'");
if (!$result || $result->rowCount() === 0) {
$db->exec("ALTER TABLE `users` ADD COLUMN `remember_token` VARCHAR(64) NULL AFTER `backup_codes`, ADD COLUMN `remember_token_expires_at` DATETIME NULL AFTER `remember_token`");
}
} catch (Throwable $e) {
// Stil negeren indien al aanwezig of tijdens migraties
}
}
/**
* Garandeert dat de kolom voor notificatie-instellingen aanwezig is in MariaDB
*/
function ensure_notification_columns(PDO $db): void {
static $notifColumnsChecked = false;
if ($notifColumnsChecked) return;
$notifColumnsChecked = true;
try {
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'notification_settings'");
if (!$result || $result->rowCount() === 0) {
$db->exec("ALTER TABLE `users` ADD COLUMN `notification_settings` TEXT NULL AFTER `remember_token_expires_at`");
}
} catch (Throwable $e) {
// Stil negeren indien al aanwezig
}
}
/**
* Garandeert dat de kolom voor weergavethema (dark, light, system) aanwezig is in MariaDB
*/
function ensure_theme_column(PDO $db): void {
static $themeChecked = false;
if ($themeChecked) return;
$themeChecked = true;
try {
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'theme'");
if (!$result || $result->rowCount() === 0) {
$db->exec("ALTER TABLE `users` ADD COLUMN `theme` VARCHAR(20) NOT NULL DEFAULT 'dark' AFTER `notification_settings`");
}
} catch (Throwable $e) {
// Stil negeren indien al aanwezig
}
}
/**
* Genereert en bewaart een persistente 'Onthoud mij' token in MariaDB en plaatst een veilige cookie
*/
function issue_remember_token(int $userId): string {
$rawToken = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $rawToken);
$expiresAt = date('Y-m-d H:i:s', time() + (30 * 86400)); // 30 dagen geldig
$db = get_db_connection();
ensure_remember_token_columns($db);
$stmt = $db->prepare("UPDATE users SET remember_token = ?, remember_token_expires_at = ? WHERE id = ?");
$stmt->execute([$tokenHash, $expiresAt, $userId]);
$isSecure = is_https_request();
setcookie('fociz_remember', $rawToken, [
'expires' => time() + (30 * 86400),
'path' => '/',
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Lax'
]);
return $rawToken;
}
/**
* Verwijdert de persistente remember cookie
*/
function clear_remember_cookie(): void {
$isSecure = is_https_request();
setcookie('fociz_remember', '', [
'expires' => time() - 86400,
'path' => '/',
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Lax'
]);
}
/**
* Haalt de huidige ingelogde gebruiker op uit de database.
* Herstelt automatisch de sessie als een geldige 'fociz_remember' cookie aanwezig is.
*/
function get_logged_in_user(): ?array {
start_secure_session();
$db = get_db_connection();
ensure_remember_token_columns($db);
// 1. Controleer actieve PHP sessie
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
if ($user) {
return $user;
}
// Gebruiker bestaat niet meer in database
$_SESSION = [];
}
// 2. Controleer persistente 'remember me' cookie (voorkomt ongewenst uitloggen bij refresh of herstart)
if (!empty($_COOKIE['fociz_remember'])) {
$rawToken = trim($_COOKIE['fociz_remember']);
$tokenHash = hash('sha256', $rawToken);
$stmt = $db->prepare("
SELECT id, name, email, role, two_factor_enabled, created_at
FROM users
WHERE remember_token = ? AND remember_token_expires_at > NOW()
");
$stmt->execute([$tokenHash]);
$user = $stmt->fetch();
if ($user) {
// Herstel volledige PHP sessie
$_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;
// Verleng direct de geldigheid van de remember token voor nog eens 30 dagen
issue_remember_token((int)$user['id']);
return $user;
} else {
clear_remember_cookie();
}
}
return null;
}
/**
* Beschermt pagina's: dwingt login + 2FA af
*/
function require_auth(bool $json = false): array {
start_secure_session();
$isAjax = $json ||
(isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) ||
(isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest');
// 1. Controleer of eerste setup nodig is
if (!has_admin_user()) {
if ($isAjax) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Setup vereist']);
exit;
}
header('Location: /auth/setup.php');
exit;
}
// 2. Controleer of gebruiker ingelogd én 2FA geverifieerd is
$user = get_logged_in_user();
if (!$user) {
if ($isAjax) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Niet geauthenticeerd of sessie verlopen']);
exit;
}
header('Location: /auth/login.php');
exit;
}
return $user;
}
/**
* Dwingt beheerder (ADMIN) rol af
*/
function require_admin(): array {
$user = require_auth();
if ($user['role'] !== 'ADMIN') {
header('Location: /planner.php');
exit;
}
return $user;
}
/**
* Maakt een nieuwe uitnodiging aan voor een collega
*/
function create_invite(int $createdById, string $email, string $role = 'MEMBER'): string {
$db = get_db_connection();
$token = bin2hex(random_bytes(24));
$expiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
$stmt = $db->prepare("INSERT INTO invites (token, email, role, expires_at, created_by_id) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$token, strtolower(trim($email)), $role, $expiresAt, $createdById]);
return $token;
}
/**
* Valideert een invite token
*/
function validate_invite(string $token): ?array {
$db = get_db_connection();
$stmt = $db->prepare("
SELECT i.*, u.name as inviter_name, u.email as inviter_email
FROM invites i
JOIN users u ON i.created_by_id = u.id
WHERE i.token = ?
");
$stmt->execute([$token]);
$invite = $stmt->fetch();
if (!$invite) return null;
if ($invite['used_at'] !== null) return null;
if (strtotime($invite['expires_at']) < time()) return null;
return $invite;
}
/**
* CSRF token helpers
*/
function get_csrf_token(): string {
start_secure_session();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token(?string $token): bool {
start_secure_session();
return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Genereert een cryptografisch ondertekende OAuth state parameter waarin de user_id en timestamp veilig zijn opgeslagen.
* Dit voorkomt CSRF en maakt sessieherstel mogelijk bij cross-site OAuth redirects.
*/
function generate_oauth_state(int $userId): string {
$secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026');
$payload = [
'uid' => $userId,
'ts' => time(),
'rnd' => bin2hex(random_bytes(8))
];
$json = json_encode($payload);
$b64 = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
$sig = hash_hmac('sha256', $b64, $secret);
return $b64 . '.' . $sig;
}
/**
* Valideert een ontvangen OAuth state parameter en geeft bij succes het geverifieerde user_id terug.
* Geeft null terug bij ongeldige handtekening of verlopen state (> 30 minuten).
*/
function verify_oauth_state(string $state): ?int {
$secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026');
$parts = explode('.', $state, 2);
if (count($parts) !== 2) {
return null;
}
[$b64, $sig] = $parts;
$expectedSig = hash_hmac('sha256', $b64, $secret);
if (!hash_equals($expectedSig, $sig)) {
return null;
}
$remainder = strlen($b64) % 4;
if ($remainder) {
$b64 .= str_repeat('=', 4 - $remainder);
}
$json = base64_decode(strtr($b64, '-_', '+/'));
$payload = json_decode($json, true);
if (!is_array($payload) || empty($payload['uid']) || empty($payload['ts'])) {
return null;
}
// Geldigheidsduur: maximaal 30 minuten (1800 seconden)
if (time() - (int)$payload['ts'] > 1800) {
return null;
}
return (int)$payload['uid'];
}
/**
* Haalt de persoonlijke notificatie-instellingen van een gebruiker op
*/
function get_user_notification_settings(int $userId): array {
$db = get_db_connection();
ensure_notification_columns($db);
$defaults = [
'enabled' => 1,
'lead_time_minutes' => 5, // 0, 2, 5, 10, 15, 30
'repeat_count' => 2, // 1, 2, 3, 5
'repeat_interval_minutes' => 2, // 1, 2, 3, 5
'sound_enabled' => 1, // 0 of 1
];
try {
$stmt = $db->prepare("SELECT notification_settings FROM users WHERE id = ?");
$stmt->execute([$userId]);
$json = $stmt->fetchColumn();
if (!empty($json)) {
$saved = json_decode($json, true);
if (is_array($saved)) {
return array_merge($defaults, $saved);
}
}
} catch (Throwable $e) {
error_log("get_user_notification_settings error: " . $e->getMessage());
}
return $defaults;
}
/**
* Slaat de persoonlijke notificatie-instellingen van een gebruiker op
*/
function update_user_notification_settings(int $userId, array $settings): bool {
$db = get_db_connection();
ensure_notification_columns($db);
$current = get_user_notification_settings($userId);
$merged = [
'enabled' => !empty($settings['enabled']) ? 1 : 0,
'lead_time_minutes' => max(0, min(60, (int)($settings['lead_time_minutes'] ?? $current['lead_time_minutes']))),
'repeat_count' => max(1, min(10, (int)($settings['repeat_count'] ?? $current['repeat_count']))),
'repeat_interval_minutes' => max(1, min(30, (int)($settings['repeat_interval_minutes'] ?? $current['repeat_interval_minutes']))),
'sound_enabled' => !empty($settings['sound_enabled']) ? 1 : 0,
];
try {
$stmt = $db->prepare("UPDATE users SET notification_settings = ? WHERE id = ?");
return $stmt->execute([json_encode($merged), $userId]);
} catch (Throwable $e) {
error_log("update_user_notification_settings error: " . $e->getMessage());
return false;
}
}
/**
* Haalt het voorkeursthema op voor een gebruiker ('dark', 'light' of 'system')
*/
function get_user_theme(int $userId): string {
$db = get_db_connection();
ensure_theme_column($db);
try {
$stmt = $db->prepare("SELECT theme FROM users WHERE id = ?");
$stmt->execute([$userId]);
$theme = $stmt->fetchColumn();
if ($theme && in_array($theme, ['dark', 'light', 'system'])) {
return $theme;
}
} catch (Throwable $e) {
error_log("get_user_theme error: " . $e->getMessage());
}
return 'dark';
}
/**
* Slaat het voorkeursthema van een gebruiker op ('dark', 'light' of 'system')
*/
function update_user_theme(int $userId, string $theme): bool {
$db = get_db_connection();
ensure_theme_column($db);
$theme = in_array($theme, ['dark', 'light', 'system']) ? $theme : 'dark';
try {
$stmt = $db->prepare("UPDATE users SET theme = ? WHERE id = ?");
return $stmt->execute([$theme, $userId]);
} catch (Throwable $e) {
error_log("update_user_theme error: " . $e->getMessage());
return false;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* =========================================================================
* FOCIZ OMGEVINGSINDICATOR (DEV VS MAIN/LIVE)
* =========================================================================
*
* Dit bestandje bepaalt de flashy notificatie links bovenin het scherm.
*
* - OP DE DEV OMGEVING: $environment = 'development'; (Rood met 'Development' + radar ping)
* - OP DE MAIN OMGEVING: $environment = 'live'; (Groen met 'Live' + radar ping)
*
* U kunt dit hieronder aanpassen, of instellen via APP_ENV in .env / .env.example!
*/
require_once __DIR__ . '/../config/config.php';
// Standaard waarde voor de main branch: 'live' (kan overschreven worden via APP_ENV)
$environment = defined('APP_ENV') ? APP_ENV : 'live';
if (function_exists('fociz_env') && fociz_env('APP_ENV')) {
$environment = fociz_env('APP_ENV');
}
$isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']);
?>
<!-- FLASHY OMGEVINGSINDICATOR LINKS BOVENIN -->
<div id="fociz-env-badge" class="fixed top-2.5 left-2.5 z-[9999] pointer-events-auto select-none transition-all duration-300 hover:scale-105">
<?php if ($isLive): ?>
<!-- LIVE OMGEVING (TEAL) -->
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/90 border border-teal-500/50 shadow-xs backdrop-blur-md group">
<!-- Pulsing Teal Radar Indicator -->
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-teal-500"></span>
</span>
<span class="text-[10px] font-bold tracking-wider text-teal-400 uppercase flex items-center space-x-1">
<span>Live</span>
</span>
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.5 rounded bg-teal-500/15 text-teal-300 border border-teal-500/30">
MAIN
</span>
</div>
<?php else: ?>
<!-- DEVELOPMENT OMGEVING (ROOD) -->
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/95 border border-red-500/70 shadow-lg shadow-red-500/30 backdrop-blur-md group">
<!-- Pulsing Red Radar Indicator -->
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-80"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500 shadow-sm shadow-red-500"></span>
</span>
<span class="text-[11px] font-black tracking-wider text-red-400 uppercase flex items-center space-x-1">
<span>Development</span>
</span>
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.2 rounded bg-red-500/20 text-red-300 border border-red-500/30">
DEV
</span>
</div>
<?php endif; ?>
</div>
+26
View File
@@ -0,0 +1,26 @@
</main>
<footer class="border-t border-slate-200/80 dark:border-slate-800/60 bg-white/40 dark:bg-transparent backdrop-blur-sm py-5 text-center text-xs text-slate-500 transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col sm:flex-row items-center justify-between gap-2">
<div class="flex items-center space-x-2">
<span class="font-bold text-slate-800 dark:text-slate-300">Fociz</span>
<span>&bull;</span>
<span class="text-slate-500 dark:text-slate-400">Focus & Structuur Systeem</span>
</div>
<div>
<span class="text-slate-400 dark:text-slate-500">Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
</div>
</div>
</footer>
<!-- Fociz Agenda Notificaties Engine -->
<script src="/assets/js/notifications.js"></script>
<script>
// Lucide Icons initialiseren
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
</script>
</body>
</html>
+365
View File
@@ -0,0 +1,365 @@
<?php
/**
* Header & Navigatiebalk voor Fociz (Dual-Mode: Dark & Light, Executive Styling)
*/
require_once __DIR__ . '/auth.php';
$currentUser = $currentUser ?? require_auth();
$currentPage = basename($_SERVER['PHP_SELF']);
$currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark';
?>
<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'Fociz — Focus, Dagplanning & Roadmap') ?></title>
<!-- Anti-Flicker Theme Script: direct in head zodat er nooit een flits optreedt -->
<script>
(function() {
try {
const serverTheme = <?= json_encode($currentUserTheme ?? 'dark') ?>;
const savedTheme = localStorage.getItem('fociz_theme') || serverTheme || 'dark';
let effective = savedTheme;
if (savedTheme === 'system') {
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (effective === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch(e) {}
})();
</script>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
}
}
}
}
}
</script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
html.dark body {
background-color: #0c0f17;
color: #f8fafc;
}
html:not(.dark) body {
background-color: #f8fafc;
color: #0f172a;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
letter-spacing: -0.01em;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
html.dark ::-webkit-scrollbar-track { background: #0c0f17; }
html.dark ::-webkit-scrollbar-thumb { background: #1e2638; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #2d3852; }
html:not(.dark) ::-webkit-scrollbar-track { background: #f8fafc; }
html:not(.dark) ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
html:not(.dark) ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body class="min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white transition-colors duration-150">
<?php
// Include omgevingsindicator (Dev = Rood, Live = Groen)
require_once __DIR__ . '/env_badge.php';
?>
<?php if ($currentUser): ?>
<header class="sticky top-0 z-40 bg-white/80 dark:bg-[#0c0f17]/85 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800/60 shadow-xs transition-colors duration-150">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Brand Logo (met linker padding zodat het dev/live badge vrij staat) -->
<div class="flex items-center space-x-6 pl-28 sm:pl-36">
<a href="/planner.php" class="flex items-center space-x-2.5 group">
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 flex items-center justify-center text-white font-black text-sm shadow-sm shadow-indigo-500/20 group-hover:scale-105 transition">
F
</div>
<div class="flex flex-col">
<span class="text-lg font-bold tracking-tight text-slate-900 dark:text-white leading-none">Fociz</span>
<span class="text-[9px] text-indigo-600 dark:text-indigo-400 tracking-wider font-semibold uppercase">Focus & Structuur</span>
</div>
</a>
<!-- Nav Tabs -->
<nav class="hidden md:flex items-center space-x-1">
<a href="/planner.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'planner.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>Dagplanning</span>
</a>
<a href="/brainstorm.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="lightbulb" class="w-4 h-4"></i>
<span>Brainstorm</span>
</a>
<a href="/roadmap.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'roadmap.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Roadmap</span>
</a>
<a href="/profile.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'profile.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="user" class="w-4 h-4"></i>
<span>Mijn Profiel</span>
</a>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/users.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'users.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="users" class="w-4 h-4"></i>
<span>Team</span>
</a>
<a href="/admin.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'admin.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="shield-check" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Beheer</span>
</a>
<?php endif; ?>
</nav>
</div>
<!-- Right side: Clean User Profile & Logout -->
<div class="flex items-center space-x-2 sm:space-x-3">
<!-- Profiel link rechtsboven -->
<a href="/profile.php" class="flex items-center space-x-2.5 group hover:opacity-90 transition px-2 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60" title="Mijn Profiel & Instellingen">
<div class="w-7 h-7 rounded-lg bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 shadow-xs">
<div class="w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
</div>
</div>
<div class="text-left hidden sm:block">
<div class="text-xs font-semibold text-slate-800 dark:text-slate-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition leading-tight">
<?= htmlspecialchars($currentUser['name']) ?>
</div>
<div class="text-[10px] text-slate-500 dark:text-slate-400 leading-tight">
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
</div>
</div>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400 dark:text-slate-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition hidden sm:block"></i>
</a>
<!-- Uitloggen -->
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-xl text-slate-400 hover:text-red-600 hover:bg-red-50 dark:text-slate-400 dark:hover:text-red-400 dark:hover:bg-red-500/10 transition">
<i data-lucide="log-out" class="w-4 h-4"></i>
</a>
</div>
</div>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-200/80 dark:border-slate-800/60 text-xs">
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="calendar" class="w-4 h-4 mb-0.5"></i>
<span>Planning</span>
</a>
<a href="/brainstorm.php" class="flex flex-col items-center py-1 <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="lightbulb" class="w-4 h-4 mb-0.5"></i>
<span>Brainstorm</span>
</a>
<a href="/roadmap.php" class="flex flex-col items-center py-1 <?= $currentPage === 'roadmap.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="map" class="w-4 h-4 mb-0.5"></i>
<span>Roadmap</span>
</a>
<a href="/profile.php" class="flex flex-col items-center py-1 <?= $currentPage === 'profile.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="user" class="w-4 h-4 mb-0.5"></i>
<span>Profiel</span>
</a>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/admin.php" class="flex flex-col items-center py-1 <?= $currentPage === 'admin.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="shield" class="w-4 h-4 mb-0.5"></i>
<span>Beheer</span>
</a>
<?php endif; ?>
</div>
</div>
</header>
<!-- Fociz Agenda Notificaties Modal (Dual-Mode) -->
<div id="focizNotificationModal" class="hidden fixed inset-0 z-50 bg-black/60 dark:bg-black/80 backdrop-blur-sm items-center justify-center p-4">
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl w-full max-w-md overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<!-- Modal Header -->
<div class="p-5 border-b border-slate-100 dark:border-slate-800/80 flex items-center justify-between">
<div class="flex items-center space-x-2.5">
<div class="w-9 h-9 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-600 dark:text-blue-400 flex items-center justify-center font-bold">
<i data-lucide="bell-ring" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Agenda Push Meldingen</h3>
<p class="text-[11px] text-slate-500 dark:text-slate-400">Persoonlijke waarschuwingen bij actuele afspraken</p>
</div>
</div>
<button type="button" onclick="closeNotificationModal()" class="text-slate-400 hover:text-slate-700 dark:hover:text-white p-1 rounded-lg transition">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<!-- Inhoud Formulier -->
<form onsubmit="saveNotificationPreferences(event)" class="p-5 space-y-4 text-xs">
<!-- Browser Permissie Banner -->
<div id="notifPermGranted" class="hidden p-3 rounded-xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 text-indigo-800 dark:text-indigo-300 flex items-center justify-between">
<div class="flex items-center space-x-2">
<i data-lucide="check-circle" class="w-4 h-4 text-indigo-600 dark:text-indigo-400 flex-shrink-0"></i>
<span class="text-[11px]">Browser pushberichten zijn actief</span>
</div>
<button type="button" onclick="focizTestNotification()" class="px-2 py-1 rounded-lg bg-indigo-100 dark:bg-indigo-500/20 hover:bg-indigo-200 dark:hover:bg-indigo-500/30 text-indigo-800 dark:text-indigo-300 font-semibold text-[10px] transition">
Test Melding
</button>
</div>
<div id="notifPermRequest" class="p-3 rounded-xl bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/30 text-sky-900 dark:text-sky-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<i data-lucide="bell" class="w-4 h-4 text-sky-600 dark:text-sky-400 flex-shrink-0"></i>
<span class="text-[11px]">Toestemming browser vereist</span>
</div>
<button type="button" onclick="focizRequestPermission()" class="px-2.5 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-[10px] transition shadow-xs">
Inschakelen
</button>
</div>
<div id="notifPermDenied" class="hidden p-3 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-800 dark:text-rose-300 text-[11px] leading-relaxed">
<i data-lucide="alert-triangle" class="w-4 h-4 text-rose-600 dark:text-rose-400 inline mr-1 -mt-0.5"></i>
Browser meldingen zijn geblokkeerd. Klik op het slotje links in de adresbalk om meldingen toe te staan.
</div>
<!-- Toggle Meldingen Inschakelen -->
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
<div>
<span class="font-semibold text-slate-900 dark:text-white block">Meldingen Actief</span>
<span class="text-[10px] text-slate-500 dark:text-slate-400">Automatisch waarschuwen bij naderende afspraken</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="notifEnabledInput" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<!-- Vooraankondigingstijd -->
<div>
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
Hoeveel tijd van tevoren waarschuwen?
</label>
<select id="notifLeadTimeSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="0">0 minuten (Precies op aanvangstijd)</option>
<option value="2">2 minuten van tevoren</option>
<option value="5" selected>5 minuten van tevoren (Aanbevolen)</option>
<option value="10">10 minuten van tevoren</option>
<option value="15">15 minuten van tevoren</option>
<option value="30">30 minuten van tevoren</option>
</select>
</div>
<!-- Aantal Herhalingen & Interval Grid -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
Aantal herhalingen
</label>
<select id="notifRepeatCountSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="1">1x (Alleen 1e melding)</option>
<option value="2" selected>2x (Aanbevolen)</option>
<option value="3">3x</option>
<option value="5">5x (Intensief)</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
Herhalingsinterval
</label>
<select id="notifIntervalSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="1">Elke 1 minuut</option>
<option value="2" selected>Elke 2 minuten</option>
<option value="3">Elke 3 minuten</option>
<option value="5">Elke 5 minuten</option>
</select>
</div>
</div>
<!-- Geluidssignaal Toggle -->
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
<div class="flex items-center space-x-2">
<i data-lucide="volume-2" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<div>
<span class="font-semibold text-slate-900 dark:text-white block">Geluidssignaal</span>
<span class="text-[10px] text-slate-500 dark:text-slate-400">Zachte chime afspelen bij melding</span>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="notifSoundInput" checked class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<div id="notifSaveStatus" class="hidden text-indigo-600 dark:text-indigo-400 text-center font-semibold text-[11px] py-1"></div>
<!-- Knoppen -->
<div class="pt-2 flex items-center space-x-2">
<button type="button" onclick="closeNotificationModal()" class="flex-1 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold transition">
Annuleren
</button>
<button type="submit" id="saveNotifBtn" class="flex-1 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold transition shadow-sm shadow-indigo-600/20">
Voorkeuren Opslaan
</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<script>
/**
* Universele Thema-controller voor Fociz
*/
function focizSetTheme(theme, saveServer = true) {
localStorage.setItem('fociz_theme', theme);
let effective = theme;
if (theme === 'system') {
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (effective === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Update Lucide pictogrammen in header
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Update profiel preview selectoren als aanwezig op pagina
if (typeof updateProfileThemeUI === 'function') {
updateProfileThemeUI(theme);
}
if (saveServer) {
fetch('/api/theme.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: theme })
}).catch(() => {});
}
}
function focizQuickToggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
const nextTheme = isDark ? 'light' : 'dark';
focizSetTheme(nextTheme, true);
}
</script>
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* Fociz Pure PHP HTTP Client Helper
* Ondersteunt cURL met automatische fallback naar stream wrappers (file_get_contents).
*/
if (!function_exists('fociz_http_request')) {
/**
* Voert een HTTP request uit en retourneert de statuscode, body en geparsede JSON.
*
* @param string $url
* @param array $options [
* 'method' => 'GET'|'POST'|'PUT'|'DELETE',
* 'headers' => ['Header-Name: Value'] of ['Header-Name' => 'Value'],
* 'body' => string|array,
* 'timeout' => int (seconden, default 12),
* 'verify_ssl' => bool (default true)
* ]
* @return array ['status' => int, 'body' => string, 'json' => ?array, 'error' => ?string]
*/
function fociz_http_request(string $url, array $options = []): array {
$method = strtoupper($options['method'] ?? 'GET');
$timeout = (int)($options['timeout'] ?? 12);
$verifySsl = $options['verify_ssl'] ?? true;
// Normaliseer headers naar 'Name: Value' regels
$rawHeaders = $options['headers'] ?? [];
$headerLines = [];
foreach ($rawHeaders as $k => $v) {
if (is_numeric($k)) {
$headerLines[] = $v;
} else {
$headerLines[] = "{$k}: {$v}";
}
}
// Bereid payload voor
$body = $options['body'] ?? null;
if (is_array($body)) {
$hasJsonHeader = false;
foreach ($headerLines as $h) {
if (stripos($h, 'application/json') !== false) {
$hasJsonHeader = true;
break;
}
}
if ($hasJsonHeader) {
$body = json_encode($body);
} else {
$body = http_build_query($body);
$headerLines[] = 'Content-Type: application/x-www-form-urlencoded';
}
}
// Voeg standaard User-Agent toe als die er niet is
$hasUserAgent = false;
foreach ($headerLines as $h) {
if (stripos($h, 'User-Agent:') !== false) {
$hasUserAgent = true;
break;
}
}
if (!$hasUserAgent) {
$headerLines[] = 'User-Agent: Fociz-Integrations/1.0';
}
// 1. Probeer cURL indien beschikbaar
if (extension_loaded('curl')) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifySsl ? 2 : 0);
if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$curlError = curl_error($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($responseBody === false && !empty($curlError)) {
return [
'status' => $statusCode ?: 0,
'body' => '',
'json' => null,
'error' => "cURL fout: {$curlError}"
];
}
$parsedJson = json_decode((string)$responseBody, true);
return [
'status' => $statusCode,
'body' => (string)$responseBody,
'json' => is_array($parsedJson) ? $parsedJson : null,
'error' => null
];
}
// 2. Fallback: PHP Streams (file_get_contents)
$contextOptions = [
'http' => [
'method' => $method,
'header' => implode("\r\n", $headerLines),
'timeout' => $timeout,
'ignore_errors' => true // Zorgt dat we ook bij 4xx / 5xx responses de body en statuscode kunnen lezen
],
'ssl' => [
'verify_peer' => $verifySsl,
'verify_peer_name' => $verifySsl
]
];
if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
$contextOptions['http']['content'] = $body;
}
$context = stream_context_create($contextOptions);
$responseBody = @file_get_contents($url, false, $context);
$statusCode = 0;
if (!empty($http_response_header)) {
// Zoek de laatste HTTP status regel (bij eventuele redirects)
foreach (array_reverse($http_response_header) as $hdr) {
if (preg_match('#HTTP/\d\.\d\s+(\d{3})#i', $hdr, $matches)) {
$statusCode = (int)$matches[1];
break;
}
}
}
if ($responseBody === false) {
$lastErr = error_get_last();
return [
'status' => $statusCode ?: 0,
'body' => '',
'json' => null,
'error' => 'Stream fout: ' . ($lastErr['message'] ?? 'Kan verbinding niet voltooien')
];
}
$parsedJson = json_decode((string)$responseBody, true);
return [
'status' => $statusCode,
'body' => (string)$responseBody,
'json' => is_array($parsedJson) ? $parsedJson : null,
'error' => null
];
}
}
+356
View File
@@ -0,0 +1,356 @@
<?php
/**
* Atlassian Jira Cloud REST API Integratie voor Fociz
* Beheert verbindingstest, ophalen van aan de gebruiker toegewezen issues en import naar dagtaken.
*/
require_once __DIR__ . '/../http.php';
require_once __DIR__ . '/../../config/database.php';
/**
* Normaliseert het Jira domein naar een schone URL zonder slash aan het einde
*/
function jira_normalize_domain(string $domain): string {
$domain = trim($domain);
if (!preg_match('#^https?://#i', $domain)) {
$domain = 'https://' . $domain;
}
return rtrim($domain, '/');
}
/**
* Haalt het centrale bedrijfsdomein voor Atlassian Jira op
*/
function jira_get_company_domain(): string {
$domain = fociz_env('JIRA_DOMAIN', '');
if (!empty($domain)) {
return jira_normalize_domain($domain);
}
try {
$db = get_db_connection();
// Zoek bij beheerder(s) in de integrations tabel
$stmt = $db->query("
SELECT i.settings
FROM integrations i
JOIN users u ON i.user_id = u.id
WHERE u.role = 'ADMIN' AND i.provider = 'JIRA' AND i.settings IS NOT NULL
ORDER BY i.updated_at DESC
LIMIT 1
");
$row = $stmt->fetch();
if (!empty($row['settings'])) {
$settings = json_decode($row['settings'], true) ?: [];
if (!empty($settings['jira_domain'])) {
return jira_normalize_domain($settings['jira_domain']);
}
}
// Fallback naar een willekeurige JIRA rij
$fallback = $db->query("
SELECT settings FROM integrations WHERE provider = 'JIRA' AND settings LIKE '%jira_domain%' LIMIT 1
")->fetch();
if (!empty($fallback['settings'])) {
$settings = json_decode($fallback['settings'], true) ?: [];
if (!empty($settings['jira_domain'])) {
return jira_normalize_domain($settings['jira_domain']);
}
}
} catch (Throwable $e) {
error_log("jira_get_company_domain error: " . $e->getMessage());
}
return '';
}
/**
* Genereert de Basic Authentication header voor Atlassian REST API
*/
function jira_get_auth_header(string $email, string $apiToken): string {
return 'Basic ' . base64_encode(trim($email) . ':' . trim($apiToken));
}
/**
* Test de verbinding met Atlassian Jira Cloud via /rest/api/3/myself
*/
function jira_test_connection(string $domain, string $email, string $apiToken): array {
$cleanDomain = jira_normalize_domain($domain);
if (empty($cleanDomain) || empty($email) || empty($apiToken)) {
return [
'success' => false,
'error' => 'Vul een Jira domein, e-mailadres en Atlassian API token in.',
];
}
$authHeader = jira_get_auth_header($email, $apiToken);
$url = $cleanDomain . '/rest/api/3/myself';
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 10,
]);
// Fallback naar api v2 indien v3 niet beschikbaar is
if ($response['status'] === 404) {
$urlV2 = $cleanDomain . '/rest/api/2/myself';
$response = fociz_http_request($urlV2, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 10,
]);
}
if ($response['status'] === 200 && is_array($response['json'])) {
$user = $response['json'];
return [
'success' => true,
'user' => [
'displayName' => $user['displayName'] ?? 'Atlassian Gebruiker',
'emailAddress' => $user['emailAddress'] ?? $email,
'accountId' => $user['accountId'] ?? ($user['key'] ?? ''),
'active' => $user['active'] ?? true,
'timeZone' => $user['timeZone'] ?? 'Europe/Amsterdam',
],
];
}
$errMsg = 'Verbinding mislukt met Jira.';
if ($response['status'] === 401) {
$errMsg = 'Authenticatie geweigerd (HTTP 401). Controleer uw e-mailadres en zorg dat u een geldig Atlassian API token gebruikt.';
} elseif ($response['status'] === 403) {
$errMsg = 'Toegang geweigerd (HTTP 403). Uw account heeft onvoldoende rechten op dit Jira domein.';
} elseif ($response['status'] === 404) {
$errMsg = 'Jira Cloud domein niet gevonden (HTTP 404). Controleer het ingevoerde domein (bijv. uwbedrijf.atlassian.net).';
} elseif (!empty($response['error'])) {
$errMsg = "Netwerkfout: {$response['error']}";
}
return [
'success' => false,
'error' => $errMsg,
'status' => $response['status'],
];
}
/**
* Haalt alle actieve issues op die zijn toegewezen aan de ingelogde Jira gebruiker
*/
function jira_fetch_assigned_issues(string $domain, string $email, string $apiToken, int $maxResults = 50): array {
$cleanDomain = jira_normalize_domain($domain);
$authHeader = jira_get_auth_header($email, $apiToken);
// JQL: Alle openstaande taken toegewezen aan mij, gesorteerd op recent bijgewerkt
$jql = 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC';
$params = [
'jql' => $jql,
'maxResults' => min($maxResults, 100),
'fields' => 'summary,status,priority,issuetype,parent,project,duedate,updated',
];
$url = $cleanDomain . '/rest/api/3/search?' . http_build_query($params);
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 12,
]);
// Fallback naar api v2
if ($response['status'] === 404) {
$urlV2 = $cleanDomain . '/rest/api/2/search?' . http_build_query($params);
$response = fociz_http_request($urlV2, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 12,
]);
}
if ($response['status'] !== 200 || !isset($response['json']['issues'])) {
return [
'success' => false,
'error' => $response['json']['errorMessages'][0] ?? ($response['error'] ?? 'Kan Jira tickets niet ophalen'),
'issues' => [],
];
}
$rawIssues = $response['json']['issues'];
$issues = [];
foreach ($rawIssues as $item) {
$fields = $item['fields'] ?? [];
$key = $item['key'] ?? '';
$summary = $fields['summary'] ?? '(Geen titel)';
$statusName = $fields['status']['name'] ?? 'To Do';
$priorityName = $fields['priority']['name'] ?? 'Medium';
$issueTypeName = $fields['issuetype']['name'] ?? 'Taak';
$projectName = $fields['project']['name'] ?? 'Project';
$dueDate = $fields['duedate'] ?? null;
$issues[] = [
'key' => $key,
'summary' => $summary,
'status' => $statusName,
'priority' => $priorityName,
'issue_type' => $issueTypeName,
'project' => $projectName,
'due_date' => $dueDate,
'url' => "{$cleanDomain}/browse/{$key}",
'source' => 'JIRA_LIVE',
];
}
return [
'success' => true,
'issues' => $issues,
'total' => count($issues),
];
}
/**
* Haalt de Jira tickets op voor een Fociz gebruiker
* Schakelt naadloos tussen live Jira tickets en sandbox demotickets
*/
function jira_get_user_issues(int $userId, bool $forceRefresh = false): array {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA' AND is_enabled = 1");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
// Als de integratie niet is ingesteld of in demo mode staat, geef interactieve fallback issues terug
if (!$integration || !empty($integration['is_demo_mode'])) {
return [
'is_demo' => true,
'success' => true,
'issues' => [
[
'key' => 'FOC-101',
'summary' => 'Tweestapsverificatie (2FA TOTP) flow implementeren',
'status' => 'In Progress',
'priority' => 'High',
'issue_type' => 'Story',
'project' => 'Fociz Platform',
'due_date' => date('Y-m-d', strtotime('+2 days')),
'url' => '#',
'source' => 'SANDBOX',
],
[
'key' => 'FOC-102',
'summary' => 'Interactief whiteboard canvas voor brainstormsessies',
'status' => 'To Do',
'priority' => 'High',
'issue_type' => 'Feature',
'project' => 'Fociz Platform',
'due_date' => date('Y-m-d', strtotime('+4 days')),
'url' => '#',
'source' => 'SANDBOX',
],
[
'key' => 'FOC-103',
'summary' => 'Koppeling met Microsoft Outlook Calendar & Teams Graph API',
'status' => 'In Review',
'priority' => 'Medium',
'issue_type' => 'Task',
'project' => 'Integrations Hub',
'due_date' => date('Y-m-d'),
'url' => '#',
'source' => 'SANDBOX',
],
[
'key' => 'FOC-104',
'summary' => 'Roadmap kwartaalweergave en voortgangsindicatoren',
'status' => 'To Do',
'priority' => 'Medium',
'issue_type' => 'Story',
'project' => 'Roadmap Module',
'due_date' => date('Y-m-d', strtotime('+7 days')),
'url' => '#',
'source' => 'SANDBOX',
],
],
];
}
$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', '');
if (empty($domain) || empty($email) || empty($apiToken)) {
return [
'is_demo' => false,
'success' => false,
'error' => 'Jira configuratie is onvolledig.',
'issues' => [],
];
}
// Sessiecache voor 90 seconden om onnodige Jira API calls bij snelle pagina refreshes te beperken
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
$cacheKey = "jira_issues_user_{$userId}";
$cached = $_SESSION[$cacheKey] ?? null;
if (!$forceRefresh && $cached && is_array($cached) && ($cached['timestamp'] ?? 0) > (time() - 90)) {
return [
'is_demo' => false,
'success' => true,
'issues' => $cached['issues'],
'from_cache' => true,
];
}
$result = jira_fetch_assigned_issues($domain, $email, $apiToken, 50);
if ($result['success']) {
$_SESSION[$cacheKey] = [
'timestamp' => time(),
'issues' => $result['issues'],
];
// Werk last_sync_at bij
$stmtUpdate = $db->prepare("UPDATE integrations SET last_sync_at = NOW() WHERE id = ?");
$stmtUpdate->execute([$integration['id']]);
return [
'is_demo' => false,
'success' => true,
'issues' => $result['issues'],
];
}
return [
'is_demo' => false,
'success' => false,
'error' => $result['error'],
'issues' => [],
];
}
/**
* Ontkoppelt Jira integratie voor een gebruiker
*/
function jira_disconnect(int $userId): bool {
$db = get_db_connection();
$stmt = $db->prepare("DELETE FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
unset($_SESSION["jira_issues_user_{$userId}"]);
return $stmt->execute([$userId]);
}
+885
View File
@@ -0,0 +1,885 @@
<?php
/**
* Microsoft 365 / Graph API Integratie voor Fociz
* Beheert OAuth2 authenticatie, token refresh en kalendersynchronisatie.
*/
require_once __DIR__ . '/../http.php';
require_once __DIR__ . '/../../config/database.php';
const MS_OAUTH_AUTHORIZE_URL = 'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize';
const MS_OAUTH_TOKEN_URL = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token';
const MS_GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
const MS_DEFAULT_SCOPES = 'offline_access User.Read Calendars.ReadWrite';
/**
* Haalt de centrale Microsoft App Registratie configuratie op (Client ID, Secret, Tenant ID)
* Prioriteit:
* 1. Environment variabelen (.env of server env)
* 2. Opgeslagen configuratie van een Beheerder (ADMIN) in de integrations tabel
* 3. Fallback naar integrations tabel waar settings client_id bevat
*/
function microsoft_get_app_config(): array {
$clientId = fociz_env('MICROSOFT_CLIENT_ID', '');
$clientSecret = fociz_env('MICROSOFT_CLIENT_SECRET', '');
$tenantId = fociz_env('MICROSOFT_TENANT_ID', '');
try {
$db = get_db_connection();
$adminOrgDomain = '';
// Zoek bij actieve beheerder(s) in de integrations tabel
$stmt = $db->query("
SELECT i.settings, u.email as admin_email
FROM integrations i
JOIN users u ON i.user_id = u.id
WHERE u.role = 'ADMIN' AND i.provider = 'OUTLOOK' AND i.settings IS NOT NULL
ORDER BY i.updated_at DESC
LIMIT 5
");
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
if (!empty($row['admin_email']) && empty($adminOrgDomain)) {
$emailParts = explode('@', $row['admin_email']);
$domain = strtolower(trim(end($emailParts)));
if (!in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) {
$adminOrgDomain = $domain;
}
}
if (!empty($row['settings'])) {
$saved = json_decode($row['settings'], true) ?: [];
if (empty($clientId) && !empty($saved['client_id'])) {
$clientId = trim($saved['client_id']);
}
if (empty($clientSecret) && !empty($saved['client_secret'])) {
$clientSecret = trim($saved['client_secret']);
}
if ((empty($tenantId) || $tenantId === 'common') && !empty($saved['tenant_id']) && $saved['tenant_id'] !== 'common') {
$tenantId = trim($saved['tenant_id']);
}
if (!empty($clientId) && !empty($clientSecret) && !empty($tenantId) && $tenantId !== 'common') {
break;
}
}
}
// Fallback: zoek een willekeurige OUTLOOK rij met ingevulde client_id
if (empty($clientId) || empty($clientSecret) || empty($tenantId) || $tenantId === 'common') {
$stmtFallback = $db->query("
SELECT settings
FROM integrations
WHERE provider = 'OUTLOOK' AND settings LIKE '%client_id%'
ORDER BY updated_at DESC
LIMIT 3
");
$fallbackRows = $stmtFallback->fetchAll();
foreach ($fallbackRows as $fbRow) {
if (!empty($fbRow['settings'])) {
$saved = json_decode($fbRow['settings'], true) ?: [];
if (empty($clientId) && !empty($saved['client_id'])) {
$clientId = trim($saved['client_id']);
}
if (empty($clientSecret) && !empty($saved['client_secret'])) {
$clientSecret = trim($saved['client_secret']);
}
if ((empty($tenantId) || $tenantId === 'common') && !empty($saved['tenant_id']) && $saved['tenant_id'] !== 'common') {
$tenantId = trim($saved['tenant_id']);
}
}
}
}
// Als tenantId nog leeg of 'common' is, leid het domein af van de beheerder
if (empty($tenantId) || $tenantId === 'common') {
if (empty($adminOrgDomain)) {
$firstAdminEmail = $db->query("SELECT email FROM users WHERE role = 'ADMIN' ORDER BY id ASC LIMIT 1")->fetchColumn();
if ($firstAdminEmail) {
$parts = explode('@', $firstAdminEmail);
$domain = strtolower(trim(end($parts)));
if (!in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) {
$adminOrgDomain = $domain;
}
}
}
if (!empty($adminOrgDomain)) {
$tenantId = $adminOrgDomain;
}
}
} catch (Throwable $e) {
error_log("microsoft_get_app_config error: " . $e->getMessage());
}
// Default tenantId voor de organisatie
if (empty($tenantId) || $tenantId === 'common') {
$tenantId = 'planningsagenda.nl';
}
return [
'client_id' => $clientId ?: '',
'client_secret' => $clientSecret ?: '',
'tenant_id' => $tenantId,
'is_configured' => !empty($clientId) && !empty($clientSecret),
];
}
/**
* Genereert de Microsoft OAuth2 login URL
*/
function microsoft_get_auth_url(string $clientId, string $redirectUri, string $tenantId = '', string $state = ''): string {
$tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl';
$baseUrl = sprintf(MS_OAUTH_AUTHORIZE_URL, urlencode($tenant));
$params = [
'client_id' => trim($clientId),
'response_type' => 'code',
'redirect_uri' => trim($redirectUri),
'response_mode' => 'query',
'scope' => MS_DEFAULT_SCOPES,
'state' => $state,
'prompt' => 'select_account',
];
return $baseUrl . '?' . http_build_query($params);
}
/**
* Wisselt de ontvangen autorisatiecode om voor access- en refresh tokens
*/
function microsoft_exchange_code(string $code, string $clientId, string $clientSecret, string $redirectUri, string $tenantId = ''): array {
$tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl';
$tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($tenant));
$payload = [
'client_id' => trim($clientId),
'client_secret' => trim($clientSecret),
'code' => trim($code),
'redirect_uri' => trim($redirectUri),
'grant_type' => 'authorization_code',
'scope' => MS_DEFAULT_SCOPES,
];
$response = fociz_http_request($tokenUrl, [
'method' => 'POST',
'body' => $payload,
'timeout' => 15,
]);
if ($response['status'] !== 200 || empty($response['json']['access_token'])) {
$errorMsg = $response['json']['error_description'] ?? ($response['error'] ?? 'Onbekende fout bij token uitwisseling');
return [
'success' => false,
'error' => $errorMsg,
];
}
return [
'success' => true,
'access_token' => $response['json']['access_token'],
'refresh_token' => $response['json']['refresh_token'] ?? null,
'expires_in' => (int)($response['json']['expires_in'] ?? 3600),
];
}
/**
* Haalt het Microsoft gebruikersprofiel op via Graph API
*/
function microsoft_get_user_profile(string $accessToken): ?array {
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me', [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$accessToken}",
'Accept' => 'application/json',
],
'timeout' => 10,
]);
if ($response['status'] === 200 && is_array($response['json'])) {
return [
'displayName' => $response['json']['displayName'] ?? 'Microsoft 365 Gebruiker',
'mail' => $response['json']['mail'] ?? ($response['json']['userPrincipalName'] ?? ''),
'id' => $response['json']['id'] ?? '',
];
}
return null;
}
/**
* Haalt een geldige access_token op voor de gebruiker; vernieuwt automatisch indien verlopen
*/
function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?string {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
if (!$integration || empty($integration['access_token'])) {
return null;
}
$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'];
}
// 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;
}
$cleanTenant = (!empty($tenantId) && $tenantId !== 'common') ? $tenantId : 'planningsagenda.nl';
$tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($cleanTenant));
$payload = [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'scope' => MS_DEFAULT_SCOPES,
];
$response = fociz_http_request($tokenUrl, [
'method' => 'POST',
'body' => $payload,
'timeout' => 15,
]);
if ($response['status'] === 200 && !empty($response['json']['access_token'])) {
$newAccessToken = $response['json']['access_token'];
$newRefreshToken = $response['json']['refresh_token'] ?? $refreshToken;
$expiresIn = (int)($response['json']['expires_in'] ?? 3600);
$settings['expires_at'] = time() + $expiresIn;
$updateStmt = $db->prepare("
UPDATE integrations
SET access_token = ?, refresh_token = ?, settings = ?, last_sync_at = NOW()
WHERE id = ?
");
$updateStmt->execute([$newAccessToken, $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'];
}
return null;
}
/**
* Haalt agenda-afspraken van Microsoft Outlook op voor een datumbereik (bijv. een hele week)
*/
function microsoft_fetch_events_range(int $userId, string $startDate, string $endDate): array {
$token = microsoft_get_valid_token($userId);
if (!$token) {
return [];
}
try {
$tzLocal = new DateTimeZone('Europe/Amsterdam');
$tzUtc = new DateTimeZone('UTC');
$dtStart = new DateTime("{$startDate} 00:00:00", $tzLocal);
$dtEnd = new DateTime("{$endDate} 23:59:59", $tzLocal);
$startIso = $dtStart->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z');
$endIso = $dtEnd->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z');
} catch (Throwable $e) {
$startIso = "{$startDate}T00:00:00Z";
$endIso = "{$endDate}T23:59:59Z";
}
$url = MS_GRAPH_BASE_URL . '/me/calendarView?' . http_build_query([
'startDateTime' => $startIso,
'endDateTime' => $endIso,
'$select' => 'id,subject,bodyPreview,start,end,location,isAllDay,webLink,onlineMeeting,isCancelled',
'$orderby' => 'start/dateTime',
'$top' => '100',
]);
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 12,
]);
// Retry bij 401 Unauthorized
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$token = $freshToken;
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 12,
]);
}
}
if ($response['status'] !== 200 || empty($response['json']['value'])) {
if ($response['status'] !== 200) {
error_log("microsoft_fetch_events_range error (HTTP {$response['status']}): " . ($response['error'] ?? json_encode($response['json'])));
}
return [];
}
$rawEvents = $response['json']['value'];
$events = [];
foreach ($rawEvents as $e) {
if (!empty($e['isCancelled'])) {
continue;
}
$rawStart = $e['start']['dateTime'] ?? '';
$rawEnd = $e['end']['dateTime'] ?? '';
$eventDate = '';
$startTime = '';
$endTime = '';
if (!empty($rawStart)) {
$eventDate = substr($rawStart, 0, 10);
if (preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) {
$startTime = $m[1];
} else {
$startTime = date('H:i', strtotime($rawStart));
}
}
if (!empty($rawEnd)) {
if (preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) {
$endTime = $m[1];
} else {
$endTime = date('H:i', strtotime($rawEnd));
}
}
$meetingUrl = $e['onlineMeeting']['joinUrl'] ?? ($e['webLink'] ?? 'https://outlook.office.com');
if (empty($meetingUrl) && !empty($e['bodyPreview'])) {
if (preg_match('#https://teams\.microsoft\.com/l/meetup-join/[^\s"\'<>]+#i', $e['bodyPreview'], $m)) {
$meetingUrl = $m[0];
}
}
$locationName = $e['location']['displayName'] ?? '';
if (empty($locationName) && !empty($e['onlineMeeting']['joinUrl'])) {
$locationName = 'Microsoft Teams';
}
$events[] = [
'id' => $e['id'] ?? uniqid('ms_'),
'subject' => !empty($e['subject']) ? $e['subject'] : '(Geen onderwerp)',
'date' => $eventDate ?: $startDate,
'start' => $startTime ?: '09:00',
'end' => $endTime ?: '10:00',
'start_raw' => $rawStart,
'end_raw' => $rawEnd,
'location' => $locationName ?: 'Geen locatie',
'meeting_url' => $meetingUrl,
'web_link' => $e['webLink'] ?? '',
'is_all_day' => !empty($e['isAllDay']),
'source' => 'OUTLOOK_LIVE',
];
}
return $events;
}
/**
* Haalt agenda-afspraken van Microsoft Outlook op voor de opgegeven datum
*/
function microsoft_fetch_today_events(int $userId, ?string $date = null): array {
$targetDate = $date ?: date('Y-m-d');
return microsoft_fetch_events_range($userId, $targetDate, $targetDate);
}
/**
* Maakt een nieuwe afspraak aan in de Microsoft Outlook agenda via Graph API
*
* @param int $userId
* @param array $eventData [
* 'subject' => string,
* 'description' => ?string,
* 'date' => ?string (Y-m-d),
* 'start_time' => ?string (H:i),
* 'end_time' => ?string (H:i),
* 'duration_minutes' => ?int,
* ]
* @return array ['success' => bool, 'event_id' => ?string, 'web_link' => ?string, 'error' => ?string]
*/
function microsoft_create_event(int $userId, array $eventData): array {
$token = microsoft_get_valid_token($userId);
if (!$token) {
return ['success' => false, 'error' => 'Geen geldige Outlook 365 verbinding gevonden. Koppel uw account via Mijn Profiel.'];
}
$subject = trim($eventData['subject'] ?? 'Fociz Dagplanning Taak');
$description = trim($eventData['description'] ?? 'Gepland via Fociz Dagplanning & Structuur');
// Normaliseer datum naar strikt YYYY-MM-DD
$date = !empty($eventData['date']) ? trim($eventData['date']) : date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d', strtotime($date) ?: time());
}
// Normaliseer starttijd naar strikt HH:mm met 2-cijferig uur
$rawStart = !empty($eventData['start_time']) ? trim($eventData['start_time']) : '09:00';
if (preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawStart, $m)) {
$startTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
} elseif (preg_match('/^(\d{1,2})$/', $rawStart, $m)) {
$startTime = sprintf('%02d:00', (int)$m[1]);
} else {
$startTime = '09:00';
}
// Duur
$duration = (int)($eventData['duration_minutes'] ?? 30);
if ($duration <= 0) $duration = 30;
// Normaliseer eindtijd naar strikt HH:mm
$rawEnd = !empty($eventData['end_time']) ? trim($eventData['end_time']) : '';
if (!empty($rawEnd) && preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawEnd, $m)) {
$endTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
} else {
try {
$dt = new DateTime("{$date} {$startTime}:00", new DateTimeZone('Europe/Amsterdam'));
$dt->modify("+{$duration} minutes");
$endTime = $dt->format('H:i');
} catch (Throwable $e) {
$endTime = '10:00';
}
}
$startDateTimeStr = "{$date}T{$startTime}:00";
$endDateTimeStr = "{$date}T{$endTime}:00";
$payload = [
'subject' => $subject,
'body' => [
'contentType' => 'HTML',
'content' => nl2br(htmlspecialchars($description)) . '<br><br><small style="color: #64748b;">Aangemaakt via <a href="https://dev.fociz.nl/planner.php">Fociz Dagplanning</a></small>',
],
'start' => [
'dateTime' => $startDateTimeStr,
'timeZone' => 'Europe/Amsterdam',
],
'end' => [
'dateTime' => $endDateTimeStr,
'timeZone' => 'Europe/Amsterdam',
],
'showAs' => 'busy',
'isReminderOn' => true,
'reminderMinutesBeforeStart' => 15,
];
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me/events', [
'method' => 'POST',
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => $payload,
'timeout' => 15,
]);
// Retry bij 401 Unauthorized (token verlopen)
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$token = $freshToken;
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me/events', [
'method' => 'POST',
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => $payload,
'timeout' => 15,
]);
}
}
if ($response['status'] === 201 && is_array($response['json'])) {
return [
'success' => true,
'event_id' => $response['json']['id'] ?? null,
'web_link' => $response['json']['webLink'] ?? null,
'start_time'=> $startTime,
'end_time' => $endTime,
];
}
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "Fout bij aanmaken Outlook afspraak (HTTP {$response['status']})");
error_log("microsoft_create_event fout (user {$userId}): {$errMsg}");
return [
'success' => false,
'error' => $errMsg,
];
}
/**
* Verwijdert een afspraak uit Microsoft Outlook via Graph API
*/
function microsoft_delete_event(int $userId, string $eventId): bool {
if (empty($eventId)) {
return false;
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return false;
}
$url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId);
$response = fociz_http_request($url, [
'method' => 'DELETE',
'headers' => [
'Authorization' => "Bearer {$token}",
],
'timeout' => 12,
]);
return in_array($response['status'], [200, 204, 404]);
}
/**
* Werkt een bestaande afspraak bij in Microsoft Outlook via Graph API (PATCH)
*/
function microsoft_update_event(int $userId, string $eventId, array $eventData): bool {
if (empty($eventId)) {
return false;
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return false;
}
$payload = [];
if (!empty($eventData['subject'])) {
$payload['subject'] = $eventData['subject'];
}
$date = !empty($eventData['date']) ? $eventData['date'] : date('Y-m-d');
$startTime = !empty($eventData['start_time']) ? $eventData['start_time'] : '09:00';
$endTime = !empty($eventData['end_time']) ? $eventData['end_time'] : '09:30';
if (preg_match('/^(\d{1,2})[:.](\d{2})$/', $startTime, $m)) {
$startTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
}
if (preg_match('/^(\d{1,2})[:.](\d{2})$/', $endTime, $m)) {
$endTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
}
$payload['start'] = [
'dateTime' => "{$date}T{$startTime}:00",
'timeZone' => 'Europe/Amsterdam',
];
$payload['end'] = [
'dateTime' => "{$date}T{$endTime}:00",
'timeZone' => 'Europe/Amsterdam',
];
$url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId);
$response = fociz_http_request($url, [
'method' => 'PATCH',
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => $payload,
'timeout' => 12,
]);
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$response = fociz_http_request($url, [
'method' => 'PATCH',
'headers' => [
'Authorization' => "Bearer {$freshToken}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => $payload,
'timeout' => 12,
]);
}
}
return in_array($response['status'], [200, 204]);
}
/**
* Ontkoppelt Microsoft integratie voor een gebruiker
* Wist uitsluitend de persoonlijke tokens; behoudt de centrale Azure App gegevens indien aanwezig.
*/
function microsoft_disconnect(int $userId): bool {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
if (!$integration) {
return true;
}
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
// Verwijder gebruikers-specifieke token gegevens
unset($settings['account_email'], $settings['account_name'], $settings['expires_at']);
// Als er centrale app credentials in staan (bijv. van beheerder), behouden we deze netjes
if (!empty($settings['client_id']) || !empty($settings['client_secret'])) {
$updateStmt = $db->prepare("
UPDATE integrations
SET access_token = NULL, refresh_token = NULL, settings = ?, last_sync_at = NULL, is_enabled = 1, is_demo_mode = 0
WHERE id = ?
");
return $updateStmt->execute([json_encode($settings), $integration['id']]);
} else {
$delStmt = $db->prepare("DELETE FROM integrations WHERE id = ?");
return $delStmt->execute([$integration['id']]);
}
}
/**
* Haalt één specifieke afspraak op uit Microsoft Outlook via Graph API.
* Retourneert:
* - array met event data indien gevonden (HTTP 200)
* - false indien definitief verwijderd / geannuleerd (HTTP 404 of isCancelled)
* - null indien er een netwerk/server error optreedt (beschermt taken tegen onterecht wissen)
*/
function microsoft_get_single_event(int $userId, string $eventId): false|array|null {
if (empty($eventId)) {
return false;
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return null;
}
$url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId) . '?' . http_build_query([
'$select' => 'id,subject,start,end,isCancelled',
]);
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 8,
]);
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$token = $freshToken;
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 8,
]);
}
}
if ($response['status'] === 404) {
return false; // Definitief niet meer gevonden in Outlook
}
if ($response['status'] === 200 && is_array($response['json'])) {
$e = $response['json'];
if (!empty($e['isCancelled'])) {
return false; // Geannuleerd
}
$rawStart = $e['start']['dateTime'] ?? '';
$rawEnd = $e['end']['dateTime'] ?? '';
$eventDate = !empty($rawStart) ? substr($rawStart, 0, 10) : '';
$startTime = '';
$endTime = '';
if (!empty($rawStart) && preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) {
$startTime = $m[1];
}
if (!empty($rawEnd) && preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) {
$endTime = $m[1];
}
return [
'id' => $e['id'],
'subject' => !empty($e['subject']) ? $e['subject'] : '(Geen onderwerp)',
'date' => $eventDate,
'start' => $startTime ?: '09:00',
'end' => $endTime ?: '10:00',
'start_raw' => $rawStart,
'end_raw' => $rawEnd,
];
}
return null;
}
/**
* Synchroniseert en reconciled Fociz taken met de live Microsoft Outlook agenda.
*
* - Als een gekoppelde afspraak in Outlook is verwijderd of geannuleerd, wordt de taak in Fociz gewist.
* - Als een afspraak in Outlook van tijd, datum of titel is gewijzigd, wordt de taak in Fociz bijgewerkt.
*
* @param int $userId
* @param string $startDate Datumbereik begin (Y-m-d)
* @param string $endDate Datumbereik einde (Y-m-d)
* @param array $liveEvents Reeds opgehaalde Outlook events via microsoft_fetch_events_range
* @return array ['deleted' => int, 'updated' => int]
*/
function microsoft_reconcile_tasks_with_outlook(int $userId, string $startDate, string $endDate, array $liveEvents): array {
$db = get_db_connection();
$deletedCount = 0;
$updatedCount = 0;
// 1. Indexeer live events op id
$liveEventMap = [];
foreach ($liveEvents as $evt) {
if (!empty($evt['id'])) {
$liveEventMap[$evt['id']] = $evt;
}
}
// 2. Haal alle taken op voor deze gebruiker in dit datumbereik met een external_id (gekoppeld aan Outlook)
$stmt = $db->prepare("
SELECT t.id, t.title, t.time_block_start, t.time_block_end, t.duration_minutes, t.external_id, dp.plan_date, dp.id as daily_plan_id
FROM tasks t
JOIN daily_plans dp ON t.daily_plan_id = dp.id
WHERE t.user_id = ?
AND t.source = 'OUTLOOK'
AND t.external_id IS NOT NULL
AND t.external_id != ''
AND dp.plan_date BETWEEN ? AND ?
");
$stmt->execute([$userId, $startDate, $endDate]);
$syncedTasks = $stmt->fetchAll();
foreach ($syncedTasks as $task) {
$extId = $task['external_id'];
if (isset($liveEventMap[$extId])) {
// Het event bestaat in het huidige week/datumbereik!
$evt = $liveEventMap[$extId];
$dateChanged = (!empty($evt['date']) && $evt['date'] !== $task['plan_date']);
$titleChanged = (!empty($evt['subject']) && $evt['subject'] !== $task['title']);
$startChanged = (!empty($evt['start']) && $evt['start'] !== $task['time_block_start']);
$endChanged = (!empty($evt['end']) && $evt['end'] !== $task['time_block_end']);
if ($dateChanged || $titleChanged || $startChanged || $endChanged) {
$targetPlanId = (int)$task['daily_plan_id'];
// Als datum gewijzigd is, verplaats naar de daily_plan van de nieuwe datum
if ($dateChanged) {
$stmtPlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
");
$stmtPlan->execute([$userId, $evt['date']]);
$targetPlanId = (int)$db->lastInsertId();
}
// Bereken duur
$newStart = !empty($evt['start']) ? $evt['start'] : $task['time_block_start'];
$newEnd = !empty($evt['end']) ? $evt['end'] : $task['time_block_end'];
$newDuration = (int)$task['duration_minutes'];
if (!empty($newStart) && !empty($newEnd)) {
$durCalc = (strtotime("2000-01-01 {$newEnd}") - strtotime("2000-01-01 {$newStart}")) / 60;
if ($durCalc > 0) {
$newDuration = (int)$durCalc;
}
}
$newTitle = !empty($evt['subject']) ? $evt['subject'] : $task['title'];
$updateStmt = $db->prepare("
UPDATE tasks
SET daily_plan_id = ?, title = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
WHERE id = ? AND user_id = ?
");
$updateStmt->execute([$targetPlanId, $newTitle, $newStart, $newEnd, $newDuration, $task['id'], $userId]);
$updatedCount++;
}
} else {
// Event niet gevonden in het weekbereik!
// Controleer specifiek of het event in Outlook gewist is of naar een andere week verplaatst
$singleEvent = microsoft_get_single_event($userId, $extId);
if ($singleEvent === false) {
// Event is definitief verwijderd of geannuleerd in Outlook!
$delStmt = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
$delStmt->execute([$task['id'], $userId]);
$deletedCount++;
} elseif (is_array($singleEvent)) {
// Event bestaat nog, maar staat op een andere datum (buiten huidig bereik)
$newDate = $singleEvent['date'];
if (!empty($newDate)) {
$stmtPlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
");
$stmtPlan->execute([$userId, $newDate]);
$newPlanId = (int)$db->lastInsertId();
$newStart = $singleEvent['start'];
$newEnd = $singleEvent['end'];
$newDuration = (int)$task['duration_minutes'];
if (!empty($newStart) && !empty($newEnd)) {
$durCalc = (strtotime("2000-01-01 {$newEnd}") - strtotime("2000-01-01 {$newStart}")) / 60;
if ($durCalc > 0) $newDuration = (int)$durCalc;
}
$updateStmt = $db->prepare("
UPDATE tasks
SET daily_plan_id = ?, title = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
WHERE id = ? AND user_id = ?
");
$updateStmt->execute([$newPlanId, $singleEvent['subject'], $newStart, $newEnd, $newDuration, $task['id'], $userId]);
$updatedCount++;
}
}
}
}
return [
'deleted' => $deletedCount,
'updated' => $updatedCount,
];
}
+114
View File
@@ -0,0 +1,114 @@
<?php
/**
* Centrale Systeeminstellingen Helper voor Fociz
* Beheert organisatie-brede werkdagstandaarden (v1.4) en AI configuratie (v1.5).
*/
require_once __DIR__ . '/../config/database.php';
/**
* Zorgt ervoor dat de system_settings tabel bestaat in MariaDB
*/
function ensure_system_settings_table(?PDO $db = null): void {
static $ensured = false;
if ($ensured) return;
try {
$pdo = $db ?: get_db_connection();
$pdo->exec("
CREATE TABLE IF NOT EXISTS `system_settings` (
`setting_key` VARCHAR(64) PRIMARY KEY,
`setting_value` TEXT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
$ensured = true;
} catch (Throwable $e) {
error_log("ensure_system_settings_table error: " . $e->getMessage());
}
}
/**
* Haalt een enkele instelling op uit MariaDB
*/
function get_system_setting(string $key, $default = null) {
try {
$db = get_db_connection();
ensure_system_settings_table($db);
$stmt = $db->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?");
$stmt->execute([$key]);
$val = $stmt->fetchColumn();
if ($val === false || $val === null) {
return $default;
}
// Decodeer JSON indien van toepassing
$decoded = json_decode($val, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
return $val;
} catch (Throwable $e) {
error_log("get_system_setting error for {$key}: " . $e->getMessage());
return $default;
}
}
/**
* Slaat een instelling op in MariaDB
*/
function set_system_setting(string $key, $value): bool {
try {
$db = get_db_connection();
ensure_system_settings_table($db);
$valToStore = is_array($value) || is_object($value) ? json_encode($value) : (string)$value;
$stmt = $db->prepare("
INSERT INTO system_settings (setting_key, setting_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()
");
return $stmt->execute([$key, $valToStore]);
} catch (Throwable $e) {
error_log("set_system_setting error for {$key}: " . $e->getMessage());
return false;
}
}
/**
* Haalt alle organisatie werkdag standaarden op (Release v1.4)
*/
function get_workday_settings(): array {
return [
'workday_start' => get_system_setting('workday_start', '08:30'),
'workday_end' => get_system_setting('workday_end', '17:30'),
'lunch_start' => get_system_setting('lunch_start', '12:30'),
'lunch_end' => get_system_setting('lunch_end', '13:00'),
'pomodoro_focus' => (int)get_system_setting('pomodoro_focus', 25),
'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50),
'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5),
'default_task_duration' => (int)get_system_setting('default_task_duration', 30),
];
}
/**
* Haalt AI configuratie-instellingen op (Release v1.5)
*/
function get_ai_settings(): array {
$envKey = fociz_env('OPENAI_API_KEY', '');
$savedKey = get_system_setting('ai_api_key', '');
$apiKey = !empty($savedKey) ? $savedKey : $envKey;
return [
'ai_enabled' => (bool)get_system_setting('ai_enabled', !empty($apiKey)),
'ai_provider' => get_system_setting('ai_provider', 'openai'),
'ai_api_key' => $apiKey,
'ai_model' => get_system_setting('ai_model', 'gpt-4o-mini'),
'has_key' => !empty($apiKey),
];
}
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* Pure PHP TOTP (RFC 6238) & 2FA Implementatie
* Volledig compatibel met Google Authenticator, Microsoft Authenticator & 1Password
*/
class TOTP {
private static string $base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/**
* Genereert een willekeurige 16-teken Base32 geheime sleutel
*/
public static function generateSecret(int $length = 16): string {
$secret = '';
$bytes = random_bytes($length);
for ($i = 0; $i < $length; $i++) {
$secret .= self::$base32Chars[ord($bytes[$i]) % 32];
}
return $secret;
}
/**
* Decodeert een Base32 string naar binaire data
*/
private static function base32Decode(string $b32): string {
$b32 = strtoupper(trim($b32));
$buffer = 0;
$bufferBits = 0;
$binary = '';
for ($i = 0; $i < strlen($b32); $i++) {
$char = $b32[$i];
$val = strpos(self::$base32Chars, $char);
if ($val === false) continue; // Negeer spaties/ongeldige tekens
$buffer = ($buffer << 5) | $val;
$bufferBits += 5;
if ($bufferBits >= 8) {
$bufferBits -= 8;
$binary .= chr(($buffer >> $bufferBits) & 0xFF);
}
}
return $binary;
}
/**
* Berekent de 6-cijferige TOTP code voor een bepaald tijdsinterval
*/
public static function getCode(string $secret, ?int $timestamp = null): string {
if ($timestamp === null) {
$timestamp = time();
}
$timeCounter = floor($timestamp / 30);
$binaryKey = self::base32Decode($secret);
// Converteer tijdsinterval naar 8-byte big-endian binaire string
$binaryTime = pack('N*', 0) . pack('N*', $timeCounter);
$hash = hash_hmac('sha1', $binaryTime, $binaryKey, true);
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
$binaryCode = (
((ord($hash[$offset]) & 0x7F) << 24) |
((ord($hash[$offset + 1]) & 0xFF) << 16) |
((ord($hash[$offset + 2]) & 0xFF) << 8) |
(ord($hash[$offset + 3]) & 0xFF)
);
$otp = $binaryCode % 1000000;
return str_pad((string)$otp, 6, '0', STR_PAD_LEFT);
}
/**
* Verifieert een ingevoerde 6-cijferige 2FA code (met 1 tijdstap speling naar voor/achter)
*/
public static function verifyCode(string $secret, string $userInputCode, int $window = 1): bool {
$userInputCode = trim(str_replace(' ', '', $userInputCode));
if (strlen($userInputCode) !== 6 || !ctype_digit($userInputCode)) {
return false;
}
$currentTime = time();
for ($i = -$window; $i <= $window; $i++) {
$calcCode = self::getCode($secret, $currentTime + ($i * 30));
if (hash_equals($calcCode, $userInputCode)) {
return true;
}
}
return false;
}
/**
* Maakt de otpauth:// URL aan voor authenticator apps
*/
public static function getOtpAuthUrl(string $email, string $secret, string $issuer = 'Fociz'): string {
return sprintf(
'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30',
rawurlencode($issuer),
rawurlencode($email),
$secret,
rawurlencode($issuer)
);
}
/**
* Genereert een QR code afbeelding URL (via QuickChart HTTPS)
*/
public static function getQrCodeUrl(string $otpAuthUrl, int $size = 220): string {
return sprintf(
'https://quickchart.io/qr?text=%s&size=%d&margin=1&ecLevel=M',
urlencode($otpAuthUrl),
$size
);
}
/**
* Genereert een lijst met eenmalige herstelcodes
*/
public static function generateBackupCodes(int $count = 8): array {
$codes = [];
for ($i = 0; $i < $count; $i++) {
$bytes = bin2hex(random_bytes(4));
$codes[] = strtoupper(substr($bytes, 0, 4) . '-' . substr($bytes, 4, 4));
}
return $codes;
}
}