Compare commits
25 Commits
dev
..
8843b7bb9d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8843b7bb9d | |||
| 67b3da5f69 | |||
| bf83949396 | |||
| ce0dc64e76 | |||
| af9c9cc322 | |||
| c7408db956 | |||
| 17b0557d1d | |||
| 36e38b4df3 | |||
| 39f770b28b | |||
| 8caac5c524 | |||
| cc42f16e3b | |||
| 800c3043de | |||
| bf2c1f8a25 | |||
| 4af0a31738 | |||
| 88d6cd8b01 | |||
| c1a9da4173 | |||
| 97a16b8554 | |||
| ec233f3f9c | |||
| f22417e6e3 | |||
| 1ed8c2c7f7 | |||
| 4d8c10f638 | |||
| 2c5aa29e51 | |||
| 9361cd1736 | |||
| c09f9b553b | |||
| 9da056b480 |
@@ -0,0 +1,26 @@
|
||||
# MariaDB Verbinding
|
||||
DB_HOST="127.0.0.1"
|
||||
DB_PORT="3306"
|
||||
DB_NAME="fociz"
|
||||
DB_USER="root"
|
||||
DB_PASS=""
|
||||
|
||||
# Omgeving: 'development' (knippert rood) of 'live' (knippert groen)
|
||||
APP_ENV="development"
|
||||
|
||||
# Applicatie URL (voor uitnodigingslinks)
|
||||
APP_URL="https://uw-domein.nl"
|
||||
|
||||
# Applicatie Geheime Sleutel (willekeurige tekenreeks)
|
||||
APP_SECRET="fociz-maria-db-super-secret-key-998877"
|
||||
|
||||
# Microsoft 365 / Outlook (Optionele globale defaults; kan ook via web UI worden ingesteld)
|
||||
MICROSOFT_CLIENT_ID=""
|
||||
MICROSOFT_CLIENT_SECRET=""
|
||||
MICROSOFT_TENANT_ID="common"
|
||||
|
||||
# Atlassian Jira Cloud (Optionele globale defaults; kan ook via web UI worden ingesteld)
|
||||
JIRA_DOMAIN=""
|
||||
JIRA_EMAIL=""
|
||||
JIRA_API_TOKEN=""
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
# Automatische detectie van tekstbestanden en consistente line endings (LF)
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binaire bestanden
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
# Sluit repository metadata uit bij release archieven
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
# Zorg dat omgevingsspecifieke bestanden nooit worden overschreven bij merges tussen dev en main
|
||||
config/config.php merge=ours
|
||||
includes/env_badge.php merge=ours
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# Environment
|
||||
/.env
|
||||
/.env.local
|
||||
*.local
|
||||
!config/.env
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# Systeem & Editor
|
||||
.DS_Store
|
||||
@@ -10,9 +8,3 @@ Thumbs.db
|
||||
*.log
|
||||
.vscode/
|
||||
.idea/
|
||||
.continue/
|
||||
.continueignore
|
||||
|
||||
# Uploads
|
||||
/assets/uploads/*
|
||||
!/assets/uploads/**/.gitkeep
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# =========================================================================
|
||||
# Fociz Pentest & Security Hardening (.htaccess)
|
||||
# =========================================================================
|
||||
|
||||
# 1. Schakel directory listing uit
|
||||
Options -Indexes
|
||||
|
||||
# 2. Blokkeer directe toegang tot verborgen systeembestanden (.env, .git, etc.)
|
||||
<FilesMatch "^\.">
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
# 3. Blokkeer directe toegang tot gevoelige bestandsformaten
|
||||
<FilesMatch "\.(sql|log|example|md|json|lock|bak|conf)$">
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
# 4. Beveiligingsheaders
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
Header unset X-Powered-By
|
||||
</IfModule>
|
||||
@@ -56,19 +56,13 @@ In het bestand **`includes/env_badge.php`**:
|
||||
- Voortgangsbalken (0 - 100%) en deadlines.
|
||||
- Gekoppeld aan de oorspronkelijke brainstormkaarten en optioneel Jira Issue Keys (bijv. `FOC-101`).
|
||||
|
||||
4. **Mijn Profiel & Persoonlijke Koppelingen (`/profile.php`)**:
|
||||
- **Microsoft Outlook & Teams**: Koppel met 1 klik uw persoonlijke werkagenda voor dagelijkse afsprakensynchronisatie en directe Teams meeting links.
|
||||
- **Atlassian Jira**: Koppel uw persoonlijke Atlassian API token om aan u toegewezen tickets in te zien en in te plannen.
|
||||
- **Browser Push Meldingen**: Configureer persoonlijke waarschuwingen (vooraankondigingstijd, herhalingsfrequentie, audio chime en live test).
|
||||
- **Account & Beveiliging**: Wachtwoord wijzigen en 2FA beveiligingsstatus.
|
||||
4. **Integraties Hub (`/integrations.php`)**:
|
||||
- **Microsoft Outlook**: Synchroniseert agenda-afspraken direct naar de dagplanning.
|
||||
- **Microsoft Teams**: Directe deelnamelinks naar online meetings en kanaalnotificaties.
|
||||
- **Atlassian Jira**: Issues synchroniseren en direct met één klik inplannen als dagtaak.
|
||||
- **Sandbox / Demo Modus**: Schakel met één vinkje tussen live productie en interactieve testdata.
|
||||
|
||||
5. **Systeembeheer & Integraties Portaal (`/admin.php` - Admin only)**:
|
||||
- Centrale Microsoft 365 Azure App Registratie (Client ID, Client Secret, Tenant ID, Admin Consent verlenen).
|
||||
- Standaard Jira bedrijfsdomein voor alle teamleden.
|
||||
- Systeem & Team Monitor (statistieken over actieve leden, agenda koppelingen en API-status).
|
||||
- Extensibiliteit Hub voor toekomstige modules (AI Co-Pilot, werkdag standaarden).
|
||||
|
||||
6. **Team & Invites Beheer (`/users.php` - Admin only)**:
|
||||
5. **Team & Invites Beheer (`/users.php` - Admin only)**:
|
||||
- Genereer beveiligde uitnodigingslinks voor collega's met kopieerknop.
|
||||
- Overzicht van actieve gebruikers, rollen en 2FA-status.
|
||||
|
||||
@@ -87,7 +81,7 @@ DB_PORT="3306"
|
||||
DB_NAME="fociz"
|
||||
DB_USER="uw_mariadb_gebruiker"
|
||||
DB_PASS="uw_mariadb_wachtwoord"
|
||||
APP_URL="https://fociz.nl"
|
||||
APP_URL="https://uw-domein.nl"
|
||||
```
|
||||
|
||||
### 3. Automatische Tabellen Aanmaak
|
||||
@@ -96,7 +90,7 @@ U hoeft **niet** handmatig SQL-commando's uit te voeren: zodra u de website voor
|
||||
*(Optioneel kunt u `config/schema.sql` ook zelf importeren via phpMyAdmin of de MariaDB CLI).*
|
||||
|
||||
### 4. Eerste Beheerder Instellen
|
||||
1. Open uw browser en ga naar `https://fociz.nl` (of `https://dev.fociz.nl`).
|
||||
1. Open uw browser en ga naar `https://uw-domein.nl`.
|
||||
2. U wordt automatisch doorverwezen naar `/auth/setup.php`.
|
||||
3. Vul uw naam, e-mailadres en gewenst wachtwoord in.
|
||||
4. Scan de QR-code met Google Authenticator of Microsoft Authenticator.
|
||||
|
||||
@@ -1,679 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* AJAX API voor AI Co-Pilot (Release v1.5)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/ai.php';
|
||||
require_once __DIR__ . '/../includes/settings.php';
|
||||
|
||||
$user = require_auth();
|
||||
$db = get_db_connection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||
|
||||
try {
|
||||
// 1. Auto-categoriseer kaartjes in een brainstormsessie
|
||||
if ($action === 'categorize_cards') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
|
||||
if (!$sessionId) {
|
||||
echo json_encode(['error' => 'Sessie ID ontbreekt']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtSession->execute([$sessionId]);
|
||||
$session = $stmtSession->fetch();
|
||||
|
||||
if (!$session) {
|
||||
echo json_encode(['error' => 'Sessie niet gevonden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtCards = $db->prepare("SELECT id, content, category FROM brainstorm_cards WHERE session_id = ?");
|
||||
$stmtCards->execute([$sessionId]);
|
||||
$cards = $stmtCards->fetchAll();
|
||||
|
||||
if (empty($cards)) {
|
||||
echo json_encode(['error' => 'Er zijn nog geen kaartjes op het canvas om te categoriseren']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$validCategories = ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins'];
|
||||
$updateStmt = $db->prepare("UPDATE brainstorm_cards SET category = ? WHERE id = ? AND session_id = ?");
|
||||
$updatedCount = 0;
|
||||
|
||||
foreach ($aiRes['categorized'] as $item) {
|
||||
$cardId = (int)($item['id'] ?? 0);
|
||||
$newCat = trim($item['category'] ?? '');
|
||||
if ($cardId > 0 && in_array($newCat, $validCategories)) {
|
||||
$updateStmt->execute([$newCat, $cardId, $sessionId]);
|
||||
$updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'updated' => $updatedCount]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Automatisch ideeën genereren voor een sessie
|
||||
if ($action === 'generate_ideas') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
$count = min(6, max(2, (int)($data['count'] ?? 4)));
|
||||
$fullBoard = !empty($data['full_board']); // Genereren over alle 4 categorieën o.b.v. briefing
|
||||
|
||||
if (!$sessionId) {
|
||||
echo json_encode(['error' => 'Sessie ID ontbreekt']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtSession->execute([$sessionId]);
|
||||
$session = $stmtSession->fetch();
|
||||
|
||||
if (!$session) {
|
||||
echo json_encode(['error' => 'Sessie niet gevonden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$authorName = 'AI Co-Pilot';
|
||||
$createdCards = [];
|
||||
|
||||
$colorMap = [
|
||||
'Ideeën' => 'amber',
|
||||
'Knelpunten' => 'rose',
|
||||
'Oplossingen' => 'teal',
|
||||
'Quick Wins' => 'indigo',
|
||||
];
|
||||
|
||||
if ($fullBoard || !empty($session['description'])) {
|
||||
// Gebruik de volledige board generator die alle 4 categorieën vult aan de hand van de briefing
|
||||
$aiRes = fociz_ai_generate_session_board(
|
||||
$session['title'],
|
||||
$session['theme'] ?? 'Algemeen',
|
||||
$session['description'] ?? '',
|
||||
$user['id']
|
||||
);
|
||||
|
||||
if ($aiRes['success'] && !empty($aiRes['cards'])) {
|
||||
$insertStmt = $db->prepare("
|
||||
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
");
|
||||
|
||||
foreach ($aiRes['cards'] as $cardItem) {
|
||||
$cText = trim($cardItem['content'] ?? '');
|
||||
$cCat = trim($cardItem['category'] ?? 'Ideeën');
|
||||
if (!isset($colorMap[$cCat])) {
|
||||
$cCat = 'Ideeën';
|
||||
}
|
||||
$cColor = $colorMap[$cCat];
|
||||
|
||||
if (!empty($cText)) {
|
||||
$insertStmt->execute([$sessionId, $cText, $cCat, $cColor, $authorName]);
|
||||
$createdCards[] = [
|
||||
'id' => (int)$db->lastInsertId(),
|
||||
'content' => $cText,
|
||||
'category' => $cCat,
|
||||
'color' => $cColor,
|
||||
'author_name' => $authorName,
|
||||
'votes' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'full_board']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: reguliere generatie
|
||||
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id'], $session['description'] ?? '');
|
||||
|
||||
$ideas = $aiRes['ideas'] ?? [];
|
||||
if (empty($ideas)) {
|
||||
$ideas = [
|
||||
"Gebruikerservaring en flow optimaliseren voor " . $session['title'],
|
||||
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
|
||||
"Koppelingen en integraties verkennen voor snellere afhandeling",
|
||||
"Feedback- en validatielus inrichten met eindgebruikers",
|
||||
];
|
||||
}
|
||||
|
||||
$insertStmt = $db->prepare("
|
||||
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
|
||||
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
|
||||
");
|
||||
|
||||
foreach ($ideas as $ideaText) {
|
||||
$ideaText = trim($ideaText);
|
||||
if (!empty($ideaText)) {
|
||||
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
|
||||
$createdCards[] = [
|
||||
'id' => (int)$db->lastInsertId(),
|
||||
'content' => $ideaText,
|
||||
'category' => 'Ideeën',
|
||||
'color' => 'amber',
|
||||
'author_name' => $authorName,
|
||||
'votes' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'ideas']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Brainstorm notitie omzetten naar Roadmap Item
|
||||
if ($action === 'summarize_to_roadmap') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$content = trim($data['content'] ?? '');
|
||||
$sessionTitle = trim($data['session_title'] ?? '');
|
||||
|
||||
if (empty($content)) {
|
||||
echo json_encode(['error' => 'Geen inhoud opgegeven om samen te vatten']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'item' => $aiRes['item']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3b. AI Brainstorm Idee Uitwerken tot concrete behapbare items
|
||||
if ($action === 'breakdown_idea') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$content = trim($data['content'] ?? '');
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
$sessionTitle = trim($data['session_title'] ?? '');
|
||||
$theme = trim($data['theme'] ?? '');
|
||||
|
||||
if (empty($content)) {
|
||||
echo json_encode(['error' => 'Geen inhoud opgegeven om uit te werken.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($sessionId > 0 && (empty($sessionTitle) || empty($theme))) {
|
||||
$stmtS = $db->prepare("SELECT title, theme FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtS->execute([$sessionId]);
|
||||
$sRow = $stmtS->fetch();
|
||||
if ($sRow) {
|
||||
if (empty($sessionTitle)) $sessionTitle = $sRow['title'] ?? '';
|
||||
if (empty($theme)) $theme = $sRow['theme'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_breakdown_idea($content, $sessionTitle, $theme, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'summary' => $aiRes['summary'],
|
||||
'items' => $aiRes['items'],
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3c. Opgesplitste AI items exporteren naar Kanban Bord, Tijdlijn of Dagplanner
|
||||
if ($action === 'save_breakdown_items') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$destination = trim($data['destination'] ?? 'kanban'); // 'kanban', 'timeline', 'planner'
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
if (empty($items) || !is_array($items)) {
|
||||
echo json_encode(['error' => 'Geen items geselecteerd om op te slaan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$isAdmin = ($user['role'] === 'ADMIN');
|
||||
$createdCount = 0;
|
||||
|
||||
if ($destination === 'kanban' || $destination === 'timeline') {
|
||||
$boardId = (int)($data['board_id'] ?? 0);
|
||||
$laneId = (int)($data['lane_id'] ?? 0);
|
||||
|
||||
if ($boardId <= 0) {
|
||||
// Pak het eerste actieve bord van de gebruiker
|
||||
if ($isAdmin) {
|
||||
$boardId = (int)$db->query("SELECT id FROM boards WHERE is_archived = 0 ORDER BY id ASC LIMIT 1")->fetchColumn();
|
||||
} else {
|
||||
$stmtB = $db->prepare("SELECT b.id FROM boards b JOIN board_members bm ON b.id = bm.board_id WHERE bm.user_id = ? AND b.is_archived = 0 ORDER BY b.id ASC LIMIT 1");
|
||||
$stmtB->execute([$user['id']]);
|
||||
$boardId = (int)$stmtB->fetchColumn();
|
||||
}
|
||||
}
|
||||
|
||||
if ($boardId <= 0) {
|
||||
echo json_encode(['error' => 'Geen actief Kanban-bord gevonden om taken aan toe te voegen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Toegangscontrole
|
||||
if (!$isAdmin) {
|
||||
$stmtCheck = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmtCheck->execute([$boardId, $user['id']]);
|
||||
if ((int)$stmtCheck->fetchColumn() === 0) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Geen toegang tot dit bord.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Als lane_id niet geldig is, pak de eerste kolom (bijv. 'To Do' of 'Backlog')
|
||||
if ($laneId <= 0) {
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
|
||||
$stmtLane->execute([$boardId]);
|
||||
$laneId = (int)$stmtLane->fetchColumn();
|
||||
}
|
||||
|
||||
if ($laneId <= 0) {
|
||||
echo json_encode(['error' => 'Het geselecteerde bord heeft geen kolommen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Datumbereik voor Tijdlijn
|
||||
$startDate = !empty($data['start_date']) ? trim($data['start_date']) : date('Y-m-d');
|
||||
$endDate = !empty($data['end_date']) ? trim($data['end_date']) : date('Y-m-d', strtotime('+3 days'));
|
||||
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtInsertCard = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
|
||||
$stmtSchedule = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
|
||||
VALUES (?, 'DAILY_RANGE', ?, ?, '09:00', '17:00', '1,2,3,4,5')
|
||||
");
|
||||
|
||||
$priorityMap = ['P1' => 'HIGH', 'P2' => 'MEDIUM', 'P3' => 'LOW'];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$title = trim($item['title'] ?? '');
|
||||
if (empty($title)) continue;
|
||||
|
||||
$desc = trim($item['description'] ?? '');
|
||||
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
|
||||
$priority = $priorityMap[$rawPri] ?? ($rawPri === 'HIGH' || $rawPri === 'LOW' ? $rawPri : 'MEDIUM');
|
||||
$color = trim($item['color'] ?? 'indigo');
|
||||
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
|
||||
$stmtInsertCard->execute([
|
||||
$boardId,
|
||||
$laneId,
|
||||
$title,
|
||||
$desc,
|
||||
$user['id'],
|
||||
$priority,
|
||||
$color,
|
||||
$maxPos + 1,
|
||||
$user['id'],
|
||||
]);
|
||||
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
|
||||
if ($destination === 'timeline' && $cardId > 0) {
|
||||
$stmtSchedule->execute([$cardId, $startDate, $endDate]);
|
||||
}
|
||||
|
||||
$createdCount++;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'destination' => $destination,
|
||||
'created_count' => $createdCount,
|
||||
'board_id' => $boardId,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($destination === 'planner') {
|
||||
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : date('Y-m-d');
|
||||
|
||||
// Zoek of maak daily plan
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $planDate]);
|
||||
$planId = (int)$stmtPlan->fetchColumn();
|
||||
|
||||
if ($planId <= 0) {
|
||||
$stmtNewPlan = $db->prepare("INSERT INTO daily_plans (user_id, plan_date, focus_goal) VALUES (?, ?, 'Brainstorm actiepunten oppakken')");
|
||||
$stmtNewPlan->execute([$user['id'], $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
$stmtInsertTask = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, duration_minutes, status, source)
|
||||
VALUES (?, ?, ?, ?, ?, 'TODO', 'BRAINSTORM')
|
||||
");
|
||||
|
||||
foreach ($items as $item) {
|
||||
$title = trim($item['title'] ?? '');
|
||||
if (empty($title)) continue;
|
||||
|
||||
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
|
||||
$priority = in_array($rawPri, ['P1', 'P2', 'P3']) ? $rawPri : 'P2';
|
||||
$duration = max(15, min(240, (int)($item['estimated_minutes'] ?? 30)));
|
||||
|
||||
$stmtInsertTask->execute([
|
||||
$user['id'],
|
||||
$planId,
|
||||
$title,
|
||||
$priority,
|
||||
$duration,
|
||||
]);
|
||||
|
||||
$createdCount++;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'destination' => 'planner',
|
||||
'created_count' => $createdCount,
|
||||
'plan_date' => $planDate,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende bestemming: ' . htmlspecialchars($destination)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3d. Test AI Verbinding (Gemini Pro / OpenAI)
|
||||
if ($action === 'test_connection') {
|
||||
try {
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = !empty($rawInput) ? (json_decode($rawInput, true) ?: []) : $_POST;
|
||||
|
||||
$tempProvider = !empty($data['provider']) ? trim((string)$data['provider']) : null;
|
||||
$tempKey = !empty($data['api_key']) ? trim((string)$data['api_key']) : null;
|
||||
$tempModel = !empty($data['model']) ? trim((string)$data['model']) : null;
|
||||
|
||||
// Als er test credentials zijn meegegeven, test direct met die credentials
|
||||
if (!empty($tempKey) && $tempKey !== '••••••••••••••••') {
|
||||
$testSettings = [
|
||||
'ai_provider' => $tempProvider ?: 'gemini',
|
||||
'ai_api_key' => $tempKey,
|
||||
'ai_model' => $tempModel ?: ($tempProvider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro'),
|
||||
];
|
||||
} else {
|
||||
// Anders haal de geconfigureerde instellingen op van user of org
|
||||
$testSettings = get_ai_settings($user['id']);
|
||||
if (!empty($tempModel)) {
|
||||
$testSettings['ai_model'] = $tempModel;
|
||||
}
|
||||
if (!empty($tempProvider)) {
|
||||
$testSettings['ai_provider'] = $tempProvider;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($testSettings['ai_api_key'])) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Geen API-sleutel ingevuld of geconfigureerd om te testen.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$provider = strtolower($testSettings['ai_provider'] ?? 'gemini');
|
||||
$model = $testSettings['ai_model'] ?? ($provider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro');
|
||||
|
||||
$t0 = microtime(true);
|
||||
|
||||
$testMessages = [
|
||||
['role' => 'user', 'content' => 'Fociz connectiviteitstest. Reageer enkel met het woord OK.'],
|
||||
];
|
||||
|
||||
if ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) {
|
||||
$aiRes = fociz_ai_chat_gemini($testMessages, ['max_tokens' => 500, 'temperature' => 0.1], $testSettings);
|
||||
} else {
|
||||
$aiRes = fociz_ai_chat_openai($testMessages, ['max_tokens' => 500, 'temperature' => 0.1], $testSettings);
|
||||
}
|
||||
|
||||
$latency = round((microtime(true) - $t0) * 1000);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $aiRes['error'] ?? 'Verbindingstest mislukt.',
|
||||
'latency_ms' => $latency,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$providerName = ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) ? 'Google Gemini Pro' : 'OpenAI';
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => "Verbinding met {$providerName} is succesvol tot stand gebracht! (Response tijd: {$latency}ms)",
|
||||
'latency_ms' => $latency,
|
||||
'provider' => $provider,
|
||||
'model' => $aiRes['model'] ?? $model,
|
||||
'zero_leak' => true,
|
||||
]);
|
||||
exit;
|
||||
} catch (Throwable $testEx) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Fout tijdens verbindingstest: ' . $testEx->getMessage(),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Slimme dagindeling berekenen voor taken rondom bezette tijdblokken
|
||||
// ZERO-DATA LEAKAGE: Geen meetingsdetails of externe tickets worden verzonden!
|
||||
if ($action === 'smart_schedule') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d');
|
||||
|
||||
// Haal daily plan op
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $date]);
|
||||
$plan = $stmtPlan->fetch();
|
||||
$planId = $plan['id'] ?? 0;
|
||||
|
||||
// ZERO-DATA LEAKAGE: Sluit expliciet alle taken uit die afkomstig zijn van Jira, Mail of Teams!
|
||||
$isToday = ($date === date('Y-m-d'));
|
||||
$stmtTasks = $db->prepare("
|
||||
SELECT t.id, t.title, t.priority, t.duration_minutes
|
||||
FROM tasks t
|
||||
WHERE t.user_id = ?
|
||||
AND (t.daily_plan_id = ? " . ($isToday ? "OR t.daily_plan_id IS NULL" : "") . ")
|
||||
AND t.is_completed = 0
|
||||
AND (t.source IS NULL OR t.source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
|
||||
AND t.external_id IS NULL
|
||||
ORDER BY t.priority ASC, t.created_at ASC
|
||||
");
|
||||
$stmtTasks->execute([$user['id'], $planId]);
|
||||
$tasks = $stmtTasks->fetchAll();
|
||||
|
||||
if (empty($tasks)) {
|
||||
echo json_encode(['error' => 'Geen openstaande Fociz taken gevonden om in te delen']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ZERO-DATA LEAKAGE: Haal meetings op maar geef UITSLUITEND start/eindtijd door (geen titel/deelnemers)
|
||||
$busyBlocks = [];
|
||||
try {
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
$rawEvents = microsoft_fetch_today_events($user['id'], $date);
|
||||
foreach ($rawEvents as $ev) {
|
||||
if (!empty($ev['start']) && !empty($ev['end'])) {
|
||||
$busyBlocks[] = [
|
||||
'start' => substr($ev['start'], 0, 5),
|
||||
'end' => substr($ev['end'], 0, 5),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Microsoft niet gekoppeld
|
||||
}
|
||||
|
||||
$workdaySettings = get_workday_settings();
|
||||
|
||||
$aiRes = fociz_ai_smart_schedule($tasks, $busyBlocks, $workdaySettings, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Werk de tijdsblokken bij in MariaDB
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE tasks
|
||||
SET time_block_start = ?,
|
||||
duration_minutes = COALESCE(?, duration_minutes)
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
|
||||
$scheduledCount = 0;
|
||||
foreach ($aiRes['scheduled'] as $s) {
|
||||
$taskId = (int)($s['id'] ?? 0);
|
||||
$startTime = trim($s['time_block_start'] ?? '');
|
||||
$duration = !empty($s['duration_minutes']) ? (int)$s['duration_minutes'] : null;
|
||||
|
||||
if ($taskId > 0 && preg_match('/^\d{1,2}:\d{2}$/', $startTime)) {
|
||||
$updateStmt->execute([$startTime, $duration, $taskId, $user['id']]);
|
||||
$scheduledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'scheduled_count' => $scheduledCount]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Persoonlijke AI Dagbriefing genereren
|
||||
// ZERO-DATA LEAKAGE: Absoluut GEEN Jira, Mail of Teams data!
|
||||
if ($action === 'daily_briefing') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d');
|
||||
$today = date('Y-m-d');
|
||||
|
||||
// Haal openstaande taken van vandaag op (ZERO-DATA LEAKAGE: GEEN externe Jira/Mail taken)
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $date]);
|
||||
$planId = $stmtPlan->fetchColumn();
|
||||
|
||||
$isToday = ($date === $today);
|
||||
$taskFilter = $planId
|
||||
? "t.daily_plan_id = " . (int)$planId . ($isToday ? " OR t.daily_plan_id IS NULL" : "")
|
||||
: ($isToday ? "t.daily_plan_id IS NULL" : "1=0");
|
||||
|
||||
$stmtTasks = $db->prepare("
|
||||
SELECT id, title, priority, time_block_start, duration_minutes
|
||||
FROM tasks t
|
||||
WHERE t.user_id = ?
|
||||
AND t.is_completed = 0
|
||||
AND ({$taskFilter})
|
||||
AND (t.source IS NULL OR t.source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
|
||||
AND t.external_id IS NULL
|
||||
ORDER BY t.priority ASC, t.time_block_start ASC
|
||||
LIMIT 15
|
||||
");
|
||||
$stmtTasks->execute([$user['id']]);
|
||||
$tasks = $stmtTasks->fetchAll();
|
||||
|
||||
// Haal gisteren niet-voltooide interne taken op
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day', strtotime($date)));
|
||||
$stmtYest = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtYest->execute([$user['id'], $yesterday]);
|
||||
$yesterdayPlanId = $stmtYest->fetchColumn();
|
||||
$unfinishedYest = [];
|
||||
if ($yesterdayPlanId) {
|
||||
$stmtUnf = $db->prepare("
|
||||
SELECT title FROM tasks
|
||||
WHERE user_id = ? AND daily_plan_id = ? AND is_completed = 0
|
||||
AND (source IS NULL OR source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
|
||||
AND external_id IS NULL
|
||||
LIMIT 5
|
||||
");
|
||||
$stmtUnf->execute([$user['id'], $yesterdayPlanId]);
|
||||
$unfinishedYest = $stmtUnf->fetchAll();
|
||||
}
|
||||
|
||||
// ZERO-DATA LEAKAGE: Alleen geanonimiseerde bezette tijdblokken (geen subject/afzender)
|
||||
$busyBlocks = [];
|
||||
try {
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
$rawEvents = microsoft_fetch_today_events($user['id'], $date);
|
||||
foreach ($rawEvents as $ev) {
|
||||
if (!empty($ev['start']) && !empty($ev['end'])) {
|
||||
$busyBlocks[] = [
|
||||
'start' => substr($ev['start'], 0, 5),
|
||||
'end' => substr($ev['end'], 0, 5),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Geen agenda beschikbaar
|
||||
}
|
||||
|
||||
$aiSettings = get_ai_settings($user['id']);
|
||||
if (empty($aiSettings['ai_enabled']) || empty($aiSettings['has_key'])) {
|
||||
echo json_encode(['error' => 'AI Co-Pilot is niet geconfigureerd']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_daily_briefing(
|
||||
$user['name'] ?? 'Jij',
|
||||
$date,
|
||||
$tasks,
|
||||
$busyBlocks,
|
||||
$unfinishedYest,
|
||||
$user['id']
|
||||
);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'greeting' => $aiRes['greeting'],
|
||||
'focus_task' => $aiRes['focus_task'],
|
||||
'tip' => $aiRes['tip'],
|
||||
'warnings' => $aiRes['warnings'],
|
||||
'date' => $date,
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende actie']);
|
||||
} catch (Throwable $e) {
|
||||
error_log("api/ai.php Throwable: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Serverfout bij AI verwerking: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
@@ -1,611 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Kanban / Trello Bord API
|
||||
* Beheert borden, kolommen/lanes, kaarten, toewijzingen, tijdspaden & Outlook 365 integratie
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$currentUser = require_auth(true);
|
||||
$db = get_db_connection();
|
||||
ensure_tables_exist($db);
|
||||
|
||||
$userId = (int)($currentUser['id'] ?? 0);
|
||||
$userRole = $currentUser['role'] ?? 'MEMBER';
|
||||
$isAdmin = ($userRole === 'ADMIN');
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
|
||||
/**
|
||||
* Controleert of de gebruiker toegang heeft tot het bord
|
||||
*/
|
||||
function check_board_access(PDO $db, int $boardId, int $userId, bool $isAdmin): bool {
|
||||
if ($isAdmin) return true;
|
||||
$stmt = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmt->execute([$boardId, $userId]);
|
||||
return ((int)$stmt->fetchColumn()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleert of de gebruiker beheerder is van het bord
|
||||
*/
|
||||
function check_board_admin(PDO $db, int $boardId, int $userId, bool $isAdmin): bool {
|
||||
if ($isAdmin) return true;
|
||||
$stmt = $db->prepare("SELECT role FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmt->execute([$boardId, $userId]);
|
||||
$role = $stmt->fetchColumn();
|
||||
return ($role === 'ADMIN');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Alle beschikbare borden ophalen voor deze gebruiker
|
||||
if ($action === 'get_boards') {
|
||||
if ($isAdmin) {
|
||||
$stmt = $db->query("
|
||||
SELECT b.*, u.name as creator_name,
|
||||
(SELECT COUNT(*) FROM board_cards WHERE board_id = b.id) as card_count
|
||||
FROM boards b
|
||||
LEFT JOIN users u ON b.created_by_id = u.id
|
||||
WHERE b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$boards = $stmt->fetchAll();
|
||||
} else {
|
||||
$stmt = $db->prepare("
|
||||
SELECT b.*, u.name as creator_name,
|
||||
(SELECT COUNT(*) FROM board_cards WHERE board_id = b.id) as card_count
|
||||
FROM boards b
|
||||
JOIN board_members bm ON b.id = bm.board_id
|
||||
LEFT JOIN users u ON b.created_by_id = u.id
|
||||
WHERE bm.user_id = ? AND b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$boards = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'boards' => $boards,
|
||||
'is_admin' => $isAdmin
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Volledige bord-data ophalen (lanes, kaarten, toewijzingen, autorisaties)
|
||||
if ($action === 'get_board_data') {
|
||||
$boardId = (int)($input['board_id'] ?? ($_GET['board_id'] ?? 0));
|
||||
|
||||
// Als geen board_id is meegegeven, pak het eerste beschikbare bord
|
||||
if ($boardId <= 0) {
|
||||
if ($isAdmin) {
|
||||
$boardId = (int)$db->query("SELECT id FROM boards WHERE is_archived = 0 ORDER BY id ASC LIMIT 1")->fetchColumn();
|
||||
} else {
|
||||
$stmtFirst = $db->prepare("SELECT b.id FROM boards b JOIN board_members bm ON b.id = bm.board_id WHERE bm.user_id = ? AND b.is_archived = 0 ORDER BY b.id ASC LIMIT 1");
|
||||
$stmtFirst->execute([$userId]);
|
||||
$boardId = (int)$stmtFirst->fetchColumn();
|
||||
}
|
||||
}
|
||||
|
||||
if ($boardId <= 0) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'has_board' => false,
|
||||
'message' => 'Geen actieve borden gevonden.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang tot dit bord. Vraag de beheerder om autorisatie.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Haal bord-informatie op
|
||||
$stmtB = $db->prepare("SELECT b.*, u.name as creator_name FROM boards b LEFT JOIN users u ON b.created_by_id = u.id WHERE b.id = ?");
|
||||
$stmtB->execute([$boardId]);
|
||||
$board = $stmtB->fetch();
|
||||
|
||||
// Haal lanes op
|
||||
$stmtL = $db->prepare("SELECT * FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC");
|
||||
$stmtL->execute([$boardId]);
|
||||
$lanes = $stmtL->fetchAll();
|
||||
|
||||
// Haal kaarten op met assignee en schedule
|
||||
$stmtC = $db->prepare("
|
||||
SELECT c.*,
|
||||
u.name as assignee_name, u.email as assignee_email, u.avatar_url as assignee_avatar,
|
||||
s.id as schedule_id, s.schedule_type, s.start_date, s.end_date, s.start_time, s.end_time, s.days_of_week, s.outlook_event_id, s.outlook_synced_at
|
||||
FROM board_cards c
|
||||
LEFT JOIN users u ON c.assignee_id = u.id
|
||||
LEFT JOIN board_card_schedules s ON c.id = s.card_id
|
||||
WHERE c.board_id = ?
|
||||
ORDER BY c.position ASC, c.id DESC
|
||||
");
|
||||
$stmtC->execute([$boardId]);
|
||||
$cards = $stmtC->fetchAll();
|
||||
|
||||
// Groepeer kaarten per lane
|
||||
$cardsByLane = [];
|
||||
foreach ($lanes as $lane) {
|
||||
$cardsByLane[$lane['id']] = [];
|
||||
}
|
||||
foreach ($cards as $card) {
|
||||
$lId = $card['lane_id'];
|
||||
if (!isset($cardsByLane[$lId])) {
|
||||
$cardsByLane[$lId] = [];
|
||||
}
|
||||
$cardsByLane[$lId][] = $card;
|
||||
}
|
||||
|
||||
// Haal teamleden en Outlook integratie status op (voor toewijzing)
|
||||
$usersStmt = $db->query("SELECT id, name, email, role, avatar_url FROM users ORDER BY name ASC");
|
||||
$teamMembers = $usersStmt->fetchAll();
|
||||
|
||||
// Check welke collega's een actieve Outlook koppeling hebben
|
||||
$outlookActiveUsers = [];
|
||||
$intStmt = $db->query("SELECT user_id FROM integrations WHERE provider = 'OUTLOOK' AND is_enabled = 1 AND access_token IS NOT NULL");
|
||||
foreach ($intStmt->fetchAll() as $row) {
|
||||
$outlookActiveUsers[(int)$row['user_id']] = true;
|
||||
}
|
||||
foreach ($teamMembers as &$tm) {
|
||||
$tm['has_outlook'] = !empty($outlookActiveUsers[(int)$tm['id']]);
|
||||
}
|
||||
unset($tm);
|
||||
|
||||
// Haal geautoriseerde leden van dit bord op
|
||||
$membersStmt = $db->prepare("SELECT user_id, role FROM board_members WHERE board_id = ?");
|
||||
$membersStmt->execute([$boardId]);
|
||||
$authorizedMembers = [];
|
||||
foreach ($membersStmt->fetchAll() as $m) {
|
||||
$authorizedMembers[(int)$m['user_id']] = $m['role'];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'has_board' => true,
|
||||
'board' => $board,
|
||||
'lanes' => $lanes,
|
||||
'cards_by_lane' => $cardsByLane,
|
||||
'team_members' => $teamMembers,
|
||||
'authorized_members' => $authorizedMembers,
|
||||
'is_board_admin' => check_board_admin($db, $boardId, $userId, $isAdmin),
|
||||
'current_user_id' => $userId,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Nieuw bord aanmaken (Beheerder)
|
||||
if ($action === 'create_board') {
|
||||
if (!$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Alleen beheerders kunnen nieuwe borden aanmaken.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$title = trim($input['title'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
|
||||
if (empty($title)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Vul een bordtitel in.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO boards (title, description, created_by_id) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$title, $description, $userId]);
|
||||
$boardId = (int)$db->lastInsertId();
|
||||
|
||||
// Voeg maker toe als beheerder van het bord
|
||||
$db->prepare("INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'ADMIN')")
|
||||
->execute([$boardId, $userId]);
|
||||
|
||||
// Standaard lanes aanmaken
|
||||
$defaultLanes = [
|
||||
['title' => 'Open', 'color' => 'slate', 'pos' => 0],
|
||||
['title' => 'In uitvoering', 'color' => 'amber', 'pos' => 1],
|
||||
['title' => 'Testen', 'color' => 'purple', 'pos' => 2],
|
||||
['title' => 'Klaar', 'color' => 'emerald', 'pos' => 3],
|
||||
];
|
||||
$laneStmt = $db->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)");
|
||||
foreach ($defaultLanes as $l) {
|
||||
$laneStmt->execute([$boardId, $l['title'], $l['color'], $l['pos']]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'board_id' => $boardId,
|
||||
'message' => 'Bord succesvol aangemaakt!'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. Bord autorisaties / leden beheren (Beheerder)
|
||||
if ($action === 'manage_members') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
if (!check_board_admin($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen beheerrechten voor dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$memberIds = $input['member_ids'] ?? [];
|
||||
if (!is_array($memberIds)) $memberIds = [];
|
||||
|
||||
// Huidige leden bijwerken
|
||||
$db->prepare("DELETE FROM board_members WHERE board_id = ?")->execute([$boardId]);
|
||||
$insertStmt = $db->prepare("INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'MEMBER')");
|
||||
foreach ($memberIds as $mId) {
|
||||
$mId = (int)$mId;
|
||||
if ($mId > 0) {
|
||||
$insertStmt->execute([$boardId, $mId]);
|
||||
}
|
||||
}
|
||||
// Zorg dat beheerder altijd lid blijft
|
||||
$db->prepare("INSERT IGNORE INTO board_members (board_id, user_id, role) VALUES (?, ?, 'ADMIN')")->execute([$boardId, $userId]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Toegangsrechten voor het bord succesvol bijgewerkt.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Kolom / Lane toevoegen of bewerken
|
||||
if ($action === 'save_lane') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$title = trim($input['title'] ?? '');
|
||||
$color = trim($input['color'] ?? 'slate');
|
||||
|
||||
if (!check_board_admin($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Alleen bordbeheerders kunnen kolommen beheren.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($title)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Vul een kolomnaam in.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($laneId > 0) {
|
||||
$stmt = $db->prepare("UPDATE board_lanes SET title = ?, color = ? WHERE id = ? AND board_id = ?");
|
||||
$stmt->execute([$title, $color, $laneId, $boardId]);
|
||||
} else {
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_lanes WHERE board_id = ?");
|
||||
$stmtMax->execute([$boardId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
$stmt = $db->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$boardId, $title, $color, $maxPos + 1]);
|
||||
$laneId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'lane_id' => $laneId, 'message' => 'Kolom opgeslagen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 6. Kolom / Lane verwijderen
|
||||
if ($action === 'delete_lane') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
|
||||
if (!check_board_admin($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen beheerrechten.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Controleer of er kaarten in staan
|
||||
$stmtCardCnt = $db->prepare("SELECT COUNT(*) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtCardCnt->execute([$laneId]);
|
||||
$cardCount = (int)$stmtCardCnt->fetchColumn();
|
||||
if ($cardCount > 0) {
|
||||
echo json_encode(['success' => false, 'error' => 'Verplaats of verwijder eerst de taken in deze kolom alvorens de kolom te verwijderen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$db->prepare("DELETE FROM board_lanes WHERE id = ? AND board_id = ?")->execute([$laneId, $boardId]);
|
||||
echo json_encode(['success' => true, 'message' => 'Kolom verwijderd.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 7. Kaart (Post-it / Opdracht) aanmaken of bewerken
|
||||
if ($action === 'save_card') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$cardId = (int)($input['card_id'] ?? 0);
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$title = trim($input['title'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
$assigneeId = !empty($input['assignee_id']) ? (int)$input['assignee_id'] : null;
|
||||
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
|
||||
$color = in_array($input['color'] ?? '', ['yellow', 'blue', 'green', 'purple', 'pink', 'orange', 'slate']) ? $input['color'] : 'yellow';
|
||||
|
||||
if (!check_board_access($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang tot dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($title)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Vul een titel in voor de taak.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($cardId > 0) {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE board_cards
|
||||
SET title = ?, description = ?, assignee_id = ?, priority = ?, color = ?, lane_id = ?
|
||||
WHERE id = ? AND board_id = ?
|
||||
");
|
||||
$stmt->execute([$title, $description, $assigneeId, $priority, $color, $laneId, $cardId, $boardId]);
|
||||
} else {
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([$boardId, $laneId, $title, $description, $assigneeId, $priority, $color, $maxPos + 1, $userId]);
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
// Tijdspad opslaan en optioneel Outlook agenda synchroniseren indien meegegeven
|
||||
$scheduleData = $input['schedule'] ?? null;
|
||||
$outlookSyncMsg = null;
|
||||
if (!empty($scheduleData) && is_array($scheduleData) && !empty($scheduleData['enabled'])) {
|
||||
$scheduleType = in_array($scheduleData['type'] ?? '', ['SINGLE', 'DAILY_RANGE']) ? $scheduleData['type'] : 'SINGLE';
|
||||
$startDate = trim($scheduleData['start_date'] ?? date('Y-m-d'));
|
||||
$endDate = ($scheduleType === 'DAILY_RANGE' && !empty($scheduleData['end_date'])) ? trim($scheduleData['end_date']) : $startDate;
|
||||
$startTime = trim($scheduleData['start_time'] ?? '14:00');
|
||||
$endTime = trim($scheduleData['end_time'] ?? '15:00');
|
||||
$daysOfWeek = trim($scheduleData['days_of_week'] ?? '1,2,3,4,5');
|
||||
$syncOutlook = !empty($scheduleData['sync_outlook']);
|
||||
|
||||
$outlookEventId = null;
|
||||
$outlookSyncedAt = null;
|
||||
|
||||
// Als Outlook sync gevraagd is én er een assignee is:
|
||||
if ($syncOutlook && $assigneeId > 0) {
|
||||
// Controleer of de assignee Outlook gekoppeld heeft
|
||||
$stmtCheckInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
|
||||
$stmtCheckInt->execute([$assigneeId]);
|
||||
$outlookInt = $stmtCheckInt->fetch();
|
||||
|
||||
if (!empty($outlookInt['access_token'])) {
|
||||
$startTs = strtotime("{$startDate} {$startTime}");
|
||||
$endTs = strtotime("{$startDate} {$endTime}");
|
||||
$durationMins = max(15, (int)(($endTs - $startTs) / 60));
|
||||
|
||||
// Maak agenda-afspraak aan in Outlook
|
||||
$eventPayload = [
|
||||
'subject' => "[Fociz Bord] {$title}",
|
||||
'description' => "Opdracht toegewezen via Fociz Bord:\n\n{$description}\n\nTijdspad: {$startTime} - {$endTime}",
|
||||
'date' => $startDate,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
'duration_minutes' => $durationMins,
|
||||
];
|
||||
|
||||
$resCreate = microsoft_create_event($assigneeId, $eventPayload);
|
||||
if (!empty($resCreate['success'])) {
|
||||
$outlookEventId = $resCreate['event_id'] ?? ($resCreate['id'] ?? null);
|
||||
$outlookSyncedAt = date('Y-m-d H:i:s');
|
||||
$outlookSyncMsg = 'Taak succesvol ingepland in de Outlook agenda van de collega!';
|
||||
} else {
|
||||
$outlookSyncMsg = 'Taak opgeslagen, maar Outlook-sync mislukt: ' . ($resCreate['error'] ?? 'onbekende fout');
|
||||
}
|
||||
} else {
|
||||
$outlookSyncMsg = 'Let op: De toegewezen collega heeft Microsoft Outlook 365 nog niet gekoppeld.';
|
||||
}
|
||||
}
|
||||
|
||||
// Opslaan in board_card_schedules
|
||||
$stmtSched = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week, outlook_event_id, outlook_synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
schedule_type = VALUES(schedule_type),
|
||||
start_date = VALUES(start_date),
|
||||
end_date = VALUES(end_date),
|
||||
start_time = VALUES(start_time),
|
||||
end_time = VALUES(end_time),
|
||||
days_of_week = VALUES(days_of_week),
|
||||
outlook_event_id = COALESCE(VALUES(outlook_event_id), outlook_event_id),
|
||||
outlook_synced_at = COALESCE(VALUES(outlook_synced_at), outlook_synced_at)
|
||||
");
|
||||
$stmtSched->execute([$cardId, $scheduleType, $startDate, $endDate, $startTime, $endTime, $daysOfWeek, $outlookEventId, $outlookSyncedAt]);
|
||||
} elseif (isset($scheduleData['enabled']) && !$scheduleData['enabled']) {
|
||||
// Tijdspad uitschakelen indien expliciet uitgezet
|
||||
$db->prepare("DELETE FROM board_card_schedules WHERE card_id = ?")->execute([$cardId]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'outlook_message' => $outlookSyncMsg,
|
||||
'message' => 'Taak succesvol opgeslagen!'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 8. Kaart verplaatsen naar een andere lane of positie (Drag & Drop / Quick move)
|
||||
if ($action === 'move_card') {
|
||||
$cardId = (int)($input['card_id'] ?? 0);
|
||||
$targetLaneId = (int)($input['target_lane_id'] ?? 0);
|
||||
$targetPosition = (int)($input['target_position'] ?? 0);
|
||||
|
||||
$stmtCard = $db->prepare("SELECT * FROM board_cards WHERE id = ?");
|
||||
$stmtCard->execute([$cardId]);
|
||||
$card = $stmtCard->fetch();
|
||||
if (!$card) {
|
||||
echo json_encode(['success' => false, 'error' => 'Taak niet gevonden.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, (int)$card['board_id'], $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Valideer of de doellane daadwerkelijk bij hetzelfde bord hoort (voorkom IDOR/lane hijacking)
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE id = ? AND board_id = ?");
|
||||
$stmtLane->execute([$targetLaneId, (int)$card['board_id']]);
|
||||
if (!$stmtLane->fetch()) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldige doellane voor dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE board_cards SET lane_id = ?, position = ? WHERE id = ?");
|
||||
$stmt->execute([$targetLaneId, $targetPosition, $cardId]);
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 9. Kaart verwijderen
|
||||
if ($action === 'delete_card') {
|
||||
$cardId = (int)($input['card_id'] ?? 0);
|
||||
|
||||
$stmtCard = $db->prepare("
|
||||
SELECT c.*, s.outlook_event_id
|
||||
FROM board_cards c
|
||||
LEFT JOIN board_card_schedules s ON c.id = s.card_id
|
||||
WHERE c.id = ?
|
||||
");
|
||||
$stmtCard->execute([$cardId]);
|
||||
$card = $stmtCard->fetch();
|
||||
|
||||
if (!$card) {
|
||||
echo json_encode(['success' => false, 'error' => 'Taak niet gevonden.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, (int)$card['board_id'], $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen rechten.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Indien gekoppeld aan Outlook van de assignee, verwijder ook daar de afspraak
|
||||
if (!empty($card['outlook_event_id']) && !empty($card['assignee_id'])) {
|
||||
try {
|
||||
microsoft_delete_event((int)$card['assignee_id'], $card['outlook_event_id']);
|
||||
} catch (Throwable $t) {}
|
||||
}
|
||||
|
||||
$db->prepare("DELETE FROM board_cards WHERE id = ?")->execute([$cardId]);
|
||||
echo json_encode(['success' => true, 'message' => 'Taak verwijderd.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 10. Tijdlijn Datums Snel Bijwerken (Startdatum & Einddatum via Gantt drag)
|
||||
if ($action === 'update_card_dates') {
|
||||
$cardId = (int)($input['card_id'] ?? 0);
|
||||
$startDate = trim($input['start_date'] ?? '');
|
||||
$endDate = trim($input['end_date'] ?? $startDate);
|
||||
|
||||
if (!$cardId || !$startDate) {
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldige parameters.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtCard = $db->prepare("SELECT * FROM board_cards WHERE id = ?");
|
||||
$stmtCard->execute([$cardId]);
|
||||
$card = $stmtCard->fetch();
|
||||
if (!$card) {
|
||||
echo json_encode(['success' => false, 'error' => 'Taak niet gevonden.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, (int)$card['board_id'], $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang tot dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtSched = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
|
||||
VALUES (?, 'DAILY_RANGE', ?, ?, '09:00', '17:00', '1,2,3,4,5')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
start_date = VALUES(start_date),
|
||||
end_date = VALUES(end_date),
|
||||
schedule_type = 'DAILY_RANGE'
|
||||
");
|
||||
$stmtSched->execute([$cardId, $startDate, $endDate]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'message' => 'Tijdlijn bijgewerkt!'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 11. Snelle Inline Taak Aanmaken (⚡ Wat moet er gedaan worden?)
|
||||
if ($action === 'quick_create_card') {
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$title = trim($input['title'] ?? '');
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$startDate = !empty($input['start_date']) ? trim($input['start_date']) : null;
|
||||
$endDate = !empty($input['end_date']) ? trim($input['end_date']) : $startDate;
|
||||
|
||||
if (!$boardId || empty($title)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Vul een titel in voor de taak.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!check_board_access($db, $boardId, $userId, $isAdmin)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Geen toegang tot dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Als geen lane_id is meegegeven, pak de 1e lane van het bord
|
||||
if ($laneId <= 0) {
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
|
||||
$stmtLane->execute([$boardId]);
|
||||
$laneId = (int)$stmtLane->fetchColumn();
|
||||
}
|
||||
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, '', NULL, 'P2', 'yellow', ?, ?)
|
||||
");
|
||||
$stmt->execute([$boardId, $laneId, $title, $maxPos + 1, $userId]);
|
||||
$newCardId = (int)$db->lastInsertId();
|
||||
|
||||
// Optioneel datums toekennen
|
||||
if ($startDate) {
|
||||
$stmtSched = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
|
||||
VALUES (?, 'DAILY_RANGE', ?, ?, '09:00', '17:00', '1,2,3,4,5')
|
||||
");
|
||||
$stmtSched->execute([$newCardId, $startDate, $endDate]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $newCardId,
|
||||
'lane_id' => $laneId,
|
||||
'message' => 'Taak direct toegevoegd!'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => false, 'error' => 'Onbekende actie.']);
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fociz Board API Fout: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Serverfout: ' . $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -47,25 +47,11 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Stemmen / Upvote op kaart (met bescherming tegen oneindig stemmen)
|
||||
// 2. Stemmen / Upvote op kaart
|
||||
if ($action === 'vote_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
|
||||
if (!isset($_SESSION['voted_cards'])) {
|
||||
$_SESSION['voted_cards'] = [];
|
||||
}
|
||||
|
||||
if (in_array($cardId, $_SESSION['voted_cards'])) {
|
||||
$stmtVotes = $db->prepare("SELECT votes FROM brainstorm_cards WHERE id = ?");
|
||||
$stmtVotes->execute([$cardId]);
|
||||
$votes = (int)$stmtVotes->fetchColumn();
|
||||
echo json_encode(['success' => false, 'error' => 'U heeft al op deze kaart gestemd.', 'votes' => $votes]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$_SESSION['voted_cards'][] = $cardId;
|
||||
|
||||
$stmt = $db->prepare("UPDATE brainstorm_cards SET votes = votes + 1 WHERE id = ?");
|
||||
$stmt->execute([$cardId]);
|
||||
|
||||
@@ -77,35 +63,11 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Kaart verwijderen (met autorisatiecontrole: auteur, sessie-eigenaar of beheerder)
|
||||
// 3. Kaart verwijderen
|
||||
if ($action === 'delete_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
|
||||
$stmtCheck = $db->prepare("
|
||||
SELECT c.*, s.created_by_id as session_owner_id
|
||||
FROM brainstorm_cards c
|
||||
JOIN brainstorm_sessions s ON c.session_id = s.id
|
||||
WHERE c.id = ?
|
||||
");
|
||||
$stmtCheck->execute([$cardId]);
|
||||
$card = $stmtCheck->fetch();
|
||||
|
||||
if (!$card) {
|
||||
echo json_encode(['error' => 'Kaart niet gevonden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$isAuthor = ($card['author_name'] === $user['name']);
|
||||
$isSessionOwner = ((int)$card['session_owner_id'] === (int)$user['id']);
|
||||
$isAdmin = ($user['role'] === 'ADMIN');
|
||||
|
||||
if (!$isAuthor && !$isSessionOwner && !$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Geen toestemming om deze kaart te verwijderen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("DELETE FROM brainstorm_cards WHERE id = ?");
|
||||
$stmt->execute([$cardId]);
|
||||
|
||||
@@ -113,200 +75,7 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3b. Kaart verplaatsen naar andere categorie/lane (Drag & Drop)
|
||||
if ($action === 'move_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
$category = trim($data['category'] ?? '');
|
||||
|
||||
$validCategories = ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins'];
|
||||
if (!$cardId || !in_array($category, $validCategories, true)) {
|
||||
echo json_encode(['error' => 'Ongeldige kaart of categorie']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Bepaal kleur behorend bij de nieuwe categorie
|
||||
$colorMap = [
|
||||
'Ideeën' => 'amber',
|
||||
'Knelpunten' => 'rose',
|
||||
'Oplossingen' => 'teal',
|
||||
'Quick Wins' => 'indigo'
|
||||
];
|
||||
$newColor = $colorMap[$category] ?? 'amber';
|
||||
|
||||
$stmt = $db->prepare("UPDATE brainstorm_cards SET category = ?, color = ? WHERE id = ?");
|
||||
$stmt->execute([$category, $newColor, $cardId]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'category' => $category,
|
||||
'color' => $newColor
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3c. Kaart bewerken (Inhoud, Categorie en/of Kleur)
|
||||
if ($action === 'update_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
$content = trim($data['content'] ?? '');
|
||||
$category = trim($data['category'] ?? '');
|
||||
|
||||
if (!$cardId || empty($content)) {
|
||||
echo json_encode(['error' => 'Kaart ID en inhoud zijn verplicht']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$colorMap = [
|
||||
'Ideeën' => 'amber',
|
||||
'Knelpunten' => 'rose',
|
||||
'Oplossingen' => 'teal',
|
||||
'Quick Wins' => 'indigo'
|
||||
];
|
||||
$color = $colorMap[$category] ?? 'amber';
|
||||
|
||||
if (!empty($category)) {
|
||||
$stmt = $db->prepare("UPDATE brainstorm_cards SET content = ?, category = ?, color = ? WHERE id = ?");
|
||||
$stmt->execute([$content, $category, $color, $cardId]);
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE brainstorm_cards SET content = ? WHERE id = ?");
|
||||
$stmt->execute([$content, $cardId]);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'card_id' => $cardId, 'content' => $content, 'category' => $category, 'color' => $color]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3d. Kaart toewijzen aan collega en inplannen in agenda (Vrij blok)
|
||||
if ($action === 'assign_and_schedule') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
$assigneeId = !empty($data['assignee_id']) ? (int)$data['assignee_id'] : null;
|
||||
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : null;
|
||||
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : null;
|
||||
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : null;
|
||||
$durationMinutes = (int)($data['duration_minutes'] ?? 30);
|
||||
$syncOutlook = !empty($data['sync_outlook']);
|
||||
|
||||
if (!$cardId) {
|
||||
echo json_encode(['error' => 'Kaart ID ontbreekt']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtCard = $db->prepare("SELECT * FROM brainstorm_cards WHERE id = ?");
|
||||
$stmtCard->execute([$cardId]);
|
||||
$card = $stmtCard->fetch();
|
||||
if (!$card) {
|
||||
echo json_encode(['error' => 'Kaart niet gevonden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Bereken eindtijd indien nodig
|
||||
if ($startTime && !$endTime && $durationMinutes > 0) {
|
||||
$tParts = explode(':', $startTime);
|
||||
$startMins = ((int)$tParts[0] * 60) + (int)($tParts[1] ?? 0);
|
||||
$endMins = $startMins + $durationMinutes;
|
||||
$endTime = sprintf('%02d:%02d', floor($endMins / 60), $endMins % 60);
|
||||
}
|
||||
|
||||
$createdTaskId = null;
|
||||
$outlookSyncMsg = null;
|
||||
|
||||
// Als er een datum & tijd is gekozen én een collega (of huidige gebruiker)
|
||||
if ($assigneeId && $planDate && $startTime) {
|
||||
// Zoek of maak daily plan
|
||||
$stmtPlan = $db->prepare("
|
||||
INSERT INTO daily_plans (user_id, plan_date)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
|
||||
");
|
||||
$stmtPlan->execute([$assigneeId, $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
|
||||
$taskTitle = "Brainstorm taak: " . mb_substr($card['content'], 0, 75);
|
||||
$taskDesc = "Afkomstig van Brainstormsessie. Inhoud:\n" . $card['content'];
|
||||
$source = 'BRAINSTORM';
|
||||
$externalId = null;
|
||||
$externalUrl = null;
|
||||
|
||||
// Indien Outlook sync gewenst is
|
||||
if ($syncOutlook) {
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
try {
|
||||
$msResult = microsoft_create_event($assigneeId, [
|
||||
'subject' => $taskTitle,
|
||||
'description' => $taskDesc,
|
||||
'date' => $planDate,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
'duration_minutes' => $durationMinutes,
|
||||
]);
|
||||
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||
$source = 'OUTLOOK';
|
||||
$externalId = $msResult['event_id'];
|
||||
$externalUrl = $msResult['web_link'] ?? null;
|
||||
$outlookSyncMsg = 'Ingepland in Outlook 365 agenda.';
|
||||
}
|
||||
} catch (Throwable $t) {
|
||||
// stil doorgaan
|
||||
}
|
||||
}
|
||||
|
||||
// Maak of update taak
|
||||
if (!empty($card['scheduled_task_id'])) {
|
||||
$stmtUpdateTask = $db->prepare("
|
||||
UPDATE tasks
|
||||
SET user_id = ?, daily_plan_id = ?, title = ?, description = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmtUpdateTask->execute([$assigneeId, $planId, $taskTitle, $taskDesc, $startTime, $endTime, $durationMinutes, $card['scheduled_task_id']]);
|
||||
$createdTaskId = (int)$card['scheduled_task_id'];
|
||||
} else {
|
||||
$stmtInsertTask = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, description, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
|
||||
VALUES (?, ?, ?, ?, 'P2', ?, ?, ?, 'TODO', ?, ?, ?)
|
||||
");
|
||||
$stmtInsertTask->execute([$assigneeId, $planId, $taskTitle, $taskDesc, $startTime, $endTime, $durationMinutes, $source, $externalId, $externalUrl]);
|
||||
$createdTaskId = (int)$db->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
// Update brainstorm kaart
|
||||
$stmtUpdateCard = $db->prepare("
|
||||
UPDATE brainstorm_cards
|
||||
SET assignee_id = ?, scheduled_date = ?, scheduled_start_time = ?, scheduled_end_time = ?, scheduled_task_id = ?
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmtUpdateCard->execute([$assigneeId, $planDate, $startTime, $endTime, $createdTaskId, $cardId]);
|
||||
|
||||
// Haal assignee naam & avatar op
|
||||
$assigneeName = null;
|
||||
$assigneeAvatar = null;
|
||||
if ($assigneeId) {
|
||||
$stmtUser = $db->prepare("SELECT name, avatar_url FROM users WHERE id = ?");
|
||||
$stmtUser->execute([$assigneeId]);
|
||||
$usr = $stmtUser->fetch();
|
||||
if ($usr) {
|
||||
$assigneeName = $usr['name'];
|
||||
$assigneeAvatar = $usr['avatar_url'];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'assignee_id' => $assigneeId,
|
||||
'assignee_name' => $assigneeName,
|
||||
'assignee_avatar' => $assigneeAvatar,
|
||||
'scheduled_date' => $planDate,
|
||||
'scheduled_start_time' => $startTime,
|
||||
'scheduled_end_time' => $endTime,
|
||||
'scheduled_task_id' => $createdTaskId,
|
||||
'outlook_message' => $outlookSyncMsg
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
// 4. Omzetten naar Roadmap!
|
||||
if ($action === 'convert_to_roadmap') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardIds = $data['card_ids'] ?? [];
|
||||
@@ -368,53 +137,6 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Brainstormsessie verwijderen (inclusief bijbehorende kaarten)
|
||||
if ($action === 'delete_session') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
|
||||
if (!$sessionId) {
|
||||
echo json_encode(['success' => false, 'error' => 'Geen geldig sessie ID opgegeven.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmtCheck = $db->prepare("SELECT id, title, created_by_id FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtCheck->execute([$sessionId]);
|
||||
$session = $stmtCheck->fetch();
|
||||
|
||||
if (!$session) {
|
||||
echo json_encode(['success' => false, 'error' => 'Brainstormsessie niet gevonden.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$isCreator = ((int)$session['created_by_id'] === (int)$user['id']);
|
||||
$isAdmin = ($user['role'] === 'ADMIN');
|
||||
|
||||
if (!$isCreator && !$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Alleen de maker van deze sessie of een beheerder kan deze verwijderen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
// Verwijder kaarten behorend bij deze sessie
|
||||
$stmtDelCards = $db->prepare("DELETE FROM brainstorm_cards WHERE session_id = ?");
|
||||
$stmtDelCards->execute([$sessionId]);
|
||||
|
||||
// Verwijder de sessie zelf
|
||||
$stmtDelSession = $db->prepare("DELETE FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtDelSession->execute([$sessionId]);
|
||||
|
||||
$db->commit();
|
||||
echo json_encode(['success' => true, 'message' => 'Brainstormsessie en alle bijbehorende kaarten zijn definitief verwijderd.']);
|
||||
exit;
|
||||
} catch (Throwable $t) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
throw $t;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende actie']);
|
||||
} catch (Exception $e) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
|
||||
@@ -28,7 +28,7 @@ try {
|
||||
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
|
||||
$stmt->execute([$user['id']]);
|
||||
$saved = json_decode($stmt->fetchColumn() ?: '{}', true);
|
||||
$apiToken = fociz_decrypt($saved['jira_api_token'] ?? '');
|
||||
$apiToken = $saved['jira_api_token'] ?? '';
|
||||
if (empty($domain)) $domain = $saved['jira_domain'] ?? '';
|
||||
if (empty($email)) $email = $saved['jira_email'] ?? '';
|
||||
}
|
||||
@@ -40,7 +40,7 @@ try {
|
||||
$settings = json_encode([
|
||||
'jira_domain' => jira_normalize_domain($domain),
|
||||
'jira_email' => $email,
|
||||
'jira_api_token' => fociz_encrypt($apiToken),
|
||||
'jira_api_token' => $apiToken,
|
||||
'account_name' => $jiraUser['displayName'] ?? '',
|
||||
'account_id' => $jiraUser['accountId'] ?? '',
|
||||
]);
|
||||
@@ -58,7 +58,6 @@ try {
|
||||
|
||||
// Wis eventuele oude sessie cache
|
||||
unset($_SESSION["jira_issues_user_{$user['id']}"]);
|
||||
unset($_SESSION["jira_dashboard_user_{$user['id']}"]);
|
||||
}
|
||||
|
||||
echo json_encode($res);
|
||||
@@ -73,214 +72,6 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2b. Jira Dashboard Data Ophalen (Toegewezen + Team issues + Stats)
|
||||
if ($action === 'get_jira_dashboard') {
|
||||
$force = !empty($_GET['force']) || !empty($_POST['force']);
|
||||
$res = jira_get_dashboard_data($user['id'], $force);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2c. Jira Issue inplannen in Dagplanner
|
||||
if ($action === 'plan_jira_task') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$key = trim($input['key'] ?? '');
|
||||
$summary = trim($input['summary'] ?? '');
|
||||
$url = trim($input['url'] ?? '');
|
||||
$planDate = !empty($input['plan_date']) ? trim($input['plan_date']) : date('Y-m-d');
|
||||
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
|
||||
$timeBlockStart = !empty($input['time_block_start']) ? trim($input['time_block_start']) : null;
|
||||
$timeBlockEnd = !empty($input['time_block_end']) ? trim($input['time_block_end']) : null;
|
||||
$durationMinutes = !empty($input['duration_minutes']) ? (int)$input['duration_minutes'] : 45;
|
||||
|
||||
if (empty($key) || empty($summary)) {
|
||||
echo json_encode(['error' => 'Ticket key en titel zijn verplicht']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$customTitle = trim($input['title'] ?? '');
|
||||
$title = !empty($customTitle) ? $customTitle : "[{$key}] {$summary}";
|
||||
|
||||
// Zoek of maak daily plan aan voor deze datum
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $planDate]);
|
||||
$planId = $stmtPlan->fetchColumn();
|
||||
|
||||
if (!$planId) {
|
||||
$stmtCreatePlan = $db->prepare("INSERT INTO daily_plans (user_id, plan_date) VALUES (?, ?)");
|
||||
$stmtCreatePlan->execute([$user['id'], $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
// Bereken eindtijd indien starttijd bekend is
|
||||
if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
|
||||
try {
|
||||
$dtEnd = new DateTime("{$planDate} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||
$dtEnd->modify("+{$durationMinutes} minutes");
|
||||
$timeBlockEnd = $dtEnd->format('H:i');
|
||||
} catch (Throwable $e) {}
|
||||
}
|
||||
|
||||
$source = 'JIRA';
|
||||
$externalId = $key;
|
||||
$externalUrl = $url;
|
||||
$outlookSyncMsg = null;
|
||||
|
||||
// Automatisch synchroniseren naar Microsoft Outlook indien gekoppeld
|
||||
if (function_exists('microsoft_create_event')) {
|
||||
try {
|
||||
$syncStartTime = !empty($timeBlockStart) ? $timeBlockStart : '09:00';
|
||||
$msResult = microsoft_create_event($user['id'], [
|
||||
'subject' => $title,
|
||||
'description' => "Jira ticket {$key} gepland via Fociz ({$priority})\n\n{$url}",
|
||||
'date' => $planDate,
|
||||
'start_time' => $syncStartTime,
|
||||
'end_time' => $timeBlockEnd,
|
||||
'duration_minutes' => $durationMinutes,
|
||||
]);
|
||||
|
||||
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||
$source = 'OUTLOOK';
|
||||
$externalId = $msResult['event_id'];
|
||||
$externalUrl = $msResult['web_link'] ?? ($url ?: 'https://outlook.office.com');
|
||||
$timeBlockStart = $msResult['start_time'] ?? $syncStartTime;
|
||||
$timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd;
|
||||
$outlookSyncMsg = 'Gesynchroniseerd naar Microsoft Outlook.';
|
||||
}
|
||||
} catch (Throwable $t) {
|
||||
error_log("Fout bij auto-sync Jira naar Outlook: " . $t->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Voeg taak toe
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'TODO', ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$user['id'],
|
||||
$planId,
|
||||
$title,
|
||||
$priority,
|
||||
$timeBlockStart,
|
||||
$timeBlockEnd,
|
||||
$durationMinutes,
|
||||
$source,
|
||||
$externalId,
|
||||
$externalUrl
|
||||
]);
|
||||
$taskId = (int)$db->lastInsertId();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'outlook_synced' => ($source === 'OUTLOOK'),
|
||||
'message' => "Ticket {$key} is succesvol ingepland voor " . date('d-m-Y', strtotime($planDate)) . "!" . ($outlookSyncMsg ? " ({$outlookSyncMsg})" : ""),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2d. Jira Issue toevoegen aan een Kanban Bord kolom
|
||||
if ($action === 'board_jira_card') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$boardId = (int)($input['board_id'] ?? 0);
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$key = trim($input['key'] ?? '');
|
||||
$summary = trim($input['summary'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
$url = trim($input['url'] ?? '');
|
||||
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
|
||||
$color = in_array($input['color'] ?? '', ['yellow', 'blue', 'green', 'purple', 'red', 'pink', 'indigo', 'orange']) ? $input['color'] : 'blue';
|
||||
|
||||
if (empty($boardId) || empty($laneId) || empty($key) || empty($summary)) {
|
||||
echo json_encode(['error' => 'Bord, kolom, ticket key en titel zijn verplicht']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Controleer toegang tot bord
|
||||
$isAdmin = ($user['role'] ?? '') === 'ADMIN';
|
||||
if (!$isAdmin) {
|
||||
$stmtCheck = $db->prepare("SELECT role FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmtCheck->execute([$boardId, $user['id']]);
|
||||
if (!$stmtCheck->fetchColumn()) {
|
||||
echo json_encode(['error' => 'Geen toegang tot dit bord']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Controleer of lane bij bord hoort
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE id = ? AND board_id = ?");
|
||||
$stmtLane->execute([$laneId, $boardId]);
|
||||
if (!$stmtLane->fetchColumn()) {
|
||||
echo json_encode(['error' => 'Kolom hoort niet bij dit bord']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$title = "[{$key}] {$summary}";
|
||||
$descFull = trim(($url ? "🔗 Jira: {$url}\n\n" : "") . $description);
|
||||
|
||||
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = {$laneId}")->fetchColumn();
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$boardId,
|
||||
$laneId,
|
||||
$title,
|
||||
$descFull,
|
||||
$user['id'],
|
||||
$priority,
|
||||
$color,
|
||||
$maxPos + 1,
|
||||
$user['id']
|
||||
]);
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'message' => "Ticket {$key} is succesvol als kaart op het bord geplaatst!",
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2e. Beschikbare borden en kolommen ophalen voor selectiedialoog
|
||||
if ($action === 'get_boards_and_lanes') {
|
||||
$isAdmin = ($user['role'] ?? '') === 'ADMIN';
|
||||
if ($isAdmin) {
|
||||
$boards = $db->query("SELECT id, title FROM boards WHERE is_archived = 0 ORDER BY id ASC")->fetchAll();
|
||||
} else {
|
||||
$stmt = $db->prepare("
|
||||
SELECT b.id, b.title
|
||||
FROM boards b
|
||||
JOIN board_members bm ON b.id = bm.board_id
|
||||
WHERE bm.user_id = ? AND b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$stmt->execute([$user['id']]);
|
||||
$boards = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
$boardIds = array_column($boards, 'id');
|
||||
$lanesByBoard = [];
|
||||
if (!empty($boardIds)) {
|
||||
$inClause = implode(',', array_map('intval', $boardIds));
|
||||
$lanes = $db->query("SELECT id, board_id, title, color FROM board_lanes WHERE board_id IN ({$inClause}) ORDER BY position ASC")->fetchAll();
|
||||
foreach ($lanes as $lane) {
|
||||
$lanesByBoard[$lane['board_id']][] = $lane;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($boards as &$b) {
|
||||
$b['lanes'] = $lanesByBoard[$b['id']] ?? [];
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'boards' => $boards]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Outlook Agenda Afspraken Ophalen
|
||||
if ($action === 'get_outlook_events') {
|
||||
$date = $_GET['date'] ?? date('Y-m-d');
|
||||
@@ -315,107 +106,6 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5a. Jira Wachtrijfilters Ophalen
|
||||
if ($action === 'get_jira_queue_filters') {
|
||||
$onlyActive = !isset($_GET['all']) && !isset($_POST['all']);
|
||||
$filters = jira_get_queue_filters($onlyActive);
|
||||
echo json_encode(['success' => true, 'filters' => $filters]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5b. Jira Wachtrijfilter Opslaan / Wijzigen (Alleen Beheerders)
|
||||
if ($action === 'save_jira_queue_filter') {
|
||||
if (($user['role'] ?? '') !== 'ADMIN') {
|
||||
echo json_encode(['error' => 'Alleen beheerders mogen wachtrijfilters aanpassen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$filterCode = strtoupper(trim($input['filter_code'] ?? ''));
|
||||
$filterName = trim($input['filter_name'] ?? '');
|
||||
$projectKey = strtoupper(trim($input['project_key'] ?? ''));
|
||||
$jqlClause = trim($input['jql_clause'] ?? '');
|
||||
$description = trim($input['description'] ?? '');
|
||||
$isActive = isset($input['is_active']) ? (int)$input['is_active'] : 1;
|
||||
$sortOrder = (int)($input['sort_order'] ?? 0);
|
||||
|
||||
if (empty($filterCode) || empty($filterName) || empty($jqlClause)) {
|
||||
echo json_encode(['error' => 'Code, naam en JQL-clausule zijn verplicht.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE jira_queue_filters
|
||||
SET filter_code = ?, filter_name = ?, project_key = ?, jql_clause = ?, description = ?, is_active = ?, sort_order = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$filterCode, $filterName, $projectKey ?: null, $jqlClause, $description, $isActive, $sortOrder, $id]);
|
||||
} else {
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO jira_queue_filters (filter_code, filter_name, project_key, jql_clause, description, is_active, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
filter_name = VALUES(filter_name),
|
||||
project_key = VALUES(project_key),
|
||||
jql_clause = VALUES(jql_clause),
|
||||
description = VALUES(description),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order),
|
||||
updated_at = NOW()
|
||||
");
|
||||
$stmt->execute([$filterCode, $filterName, $projectKey ?: null, $jqlClause, $description, $isActive, $sortOrder]);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => "Wachtrijfilter '{$filterName}' is succesvol opgeslagen."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5c. Jira Wachtrijfilter Verwijderen (Alleen Beheerders)
|
||||
if ($action === 'delete_jira_queue_filter') {
|
||||
if (($user['role'] ?? '') !== 'ADMIN') {
|
||||
echo json_encode(['error' => 'Alleen beheerders mogen wachtrijfilters verwijderen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$filterCode = trim($input['filter_code'] ?? '');
|
||||
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare("DELETE FROM jira_queue_filters WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
} elseif (!empty($filterCode)) {
|
||||
$stmt = $db->prepare("DELETE FROM jira_queue_filters WHERE filter_code = ?");
|
||||
$stmt->execute([$filterCode]);
|
||||
} else {
|
||||
echo json_encode(['error' => 'Filter ID of code ontbreekt.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Wachtrijfilter is verwijderd.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5d. Standaard Wachtrijfilter Toewijzen aan Gebruiker (Beheerders of Gebruiker zelf)
|
||||
if ($action === 'set_user_jira_filter') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$targetUserId = (int)($input['user_id'] ?? $user['id']);
|
||||
$filterCode = trim($input['filter_code'] ?? '');
|
||||
|
||||
if ($targetUserId !== (int)$user['id'] && ($user['role'] ?? '') !== 'ADMIN') {
|
||||
echo json_encode(['error' => 'Geen toestemming om instellingen van andere gebruikers te wijzigen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE users SET default_jira_filter_code = ? WHERE id = ?");
|
||||
$stmt->execute([$filterCode ?: null, $targetUserId]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Wachtrijvoorkeur is succesvol bijgewerkt.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende actie']);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Live Sync & Realtime Notificatie Hub
|
||||
* Controleert continu op nieuwe Outlook e-mails, relevante Jira tickets en agenda updates.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
require_once __DIR__ . '/../includes/integrations/jira.php';
|
||||
|
||||
$user = require_auth();
|
||||
$userId = (int)$user['id'];
|
||||
$db = get_db_connection();
|
||||
|
||||
// Geef het PHP sessieslot direct vrij zodat gelijktijdige paginanavigatie nooit geblokkeerd wordt
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
$nowTs = time();
|
||||
$nowIso = date('Y-m-d H:i:s');
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$clientLastMailId = trim($_GET['last_mail_id'] ?? '');
|
||||
$clientLastJiraKey = trim($_GET['last_jira_key'] ?? '');
|
||||
$clientCalendarHash = trim($_GET['calendar_hash'] ?? '');
|
||||
|
||||
// 1. Outlook E-mail Controle
|
||||
$mailRes = microsoft_fetch_inbox_messages($userId, 5);
|
||||
$messages = $mailRes['messages'] ?? [];
|
||||
$unreadCount = (int)($mailRes['unread_count'] ?? 0);
|
||||
$latestMail = $messages[0] ?? null;
|
||||
|
||||
$hasNewMail = false;
|
||||
$newMailPayload = null;
|
||||
|
||||
if ($latestMail && !empty($latestMail['id'])) {
|
||||
// Als de client een ander ID heeft dan de allernieuwste mail en het bericht is ongelezen
|
||||
if (!empty($clientLastMailId) && $latestMail['id'] !== $clientLastMailId && empty($latestMail['is_read'])) {
|
||||
$hasNewMail = true;
|
||||
$newMailPayload = [
|
||||
'id' => $latestMail['id'],
|
||||
'subject' => $latestMail['subject'],
|
||||
'from_name' => $latestMail['from_name'],
|
||||
'from_email' => $latestMail['from_email'],
|
||||
'received_human' => $latestMail['received_human'],
|
||||
'web_link' => $latestMail['web_link'] ?? '/mail.php',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Jira Tickets Controle (rekening houdend met persoonlijke toewijzing & default queue filter zoals DX of SD)
|
||||
$userFilterCode = $user['default_jira_filter_code'] ?? '';
|
||||
$jiraData = jira_get_user_issues($userId);
|
||||
$assignedIssues = $jiraData['assigned_issues'] ?? ($jiraData['issues'] ?? []);
|
||||
$teamIssues = $jiraData['team_issues'] ?? [];
|
||||
|
||||
$dxValues = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie'];
|
||||
$relevantIssues = [];
|
||||
|
||||
// Filter issues die specifiek relevant zijn voor deze medewerker
|
||||
$userFirstName = explode(' ', trim($user['name'] ?? ''))[0] ?? '';
|
||||
foreach (array_merge($assignedIssues, $teamIssues) as $iss) {
|
||||
$assignee = $iss['assignee_name'] ?? '';
|
||||
$isAssignedToMe = !empty($assignee) && (
|
||||
stripos($assignee, $user['name'] ?? '') !== false ||
|
||||
(!empty($userFirstName) && mb_strlen($userFirstName) >= 2 && stripos($assignee, $userFirstName) !== false) ||
|
||||
(!empty($user['email']) && stripos($assignee, $user['email']) !== false)
|
||||
);
|
||||
$ol = strtolower(trim($iss['onderwerp_logging'] ?? ''));
|
||||
|
||||
$isQueueMatch = false;
|
||||
if (!empty($userFilterCode)) {
|
||||
if ($userFilterCode === 'DX' && in_array($ol, $dxValues)) {
|
||||
$isQueueMatch = true;
|
||||
} elseif ($userFilterCode === 'SD' && ($ol === '' || $ol === 'empty' || !in_array($ol, $dxValues))) {
|
||||
$isQueueMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isAssignedToMe || $isQueueMatch) {
|
||||
$relevantIssues[$iss['key']] = $iss;
|
||||
}
|
||||
}
|
||||
|
||||
$hasNewJira = false;
|
||||
$newJiraPayload = [];
|
||||
$newestJiraKey = '';
|
||||
|
||||
if (!empty($relevantIssues)) {
|
||||
// Pak het nieuwste ticket
|
||||
$newest = reset($relevantIssues);
|
||||
$newestJiraKey = $newest['key'];
|
||||
|
||||
if (!empty($clientLastJiraKey) && $newestJiraKey !== $clientLastJiraKey) {
|
||||
$hasNewJira = true;
|
||||
$newJiraPayload = [
|
||||
'key' => $newest['key'],
|
||||
'summary' => $newest['summary'],
|
||||
'priority' => $newest['priority'] ?? 'Medium',
|
||||
'issue_type' => $newest['issue_type'] ?? 'Taak',
|
||||
'project' => $newest['project'] ?? 'SD',
|
||||
'url' => $newest['url'] ?? ('/jira.php?highlight=' . $newest['key']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Outlook Agenda Controle (Tijdlijn events van vandaag)
|
||||
$calendarChanged = false;
|
||||
$todayEvents = [];
|
||||
$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
|
||||
$stmtInt->execute([$userId]);
|
||||
$outlookInt = $stmtInt->fetch();
|
||||
|
||||
if ($outlookInt && !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode'])) {
|
||||
$todayEvents = microsoft_fetch_today_events($userId, $today);
|
||||
}
|
||||
|
||||
$calHash = md5(json_encode(array_map(function($e) {
|
||||
return ($e['id'] ?? '') . ($e['start'] ?? '') . ($e['end'] ?? '') . ($e['subject'] ?? '');
|
||||
}, $todayEvents)));
|
||||
|
||||
if (!empty($clientCalendarHash) && $calHash !== $clientCalendarHash) {
|
||||
$calendarChanged = true;
|
||||
}
|
||||
|
||||
// 4. Update sync timestamps in database
|
||||
$stmtUpdate = $db->prepare("UPDATE users SET last_mail_check_at = NOW(), last_jira_check_at = NOW() WHERE id = ?");
|
||||
$stmtUpdate->execute([$userId]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'server_time' => date('H:i:s'),
|
||||
'mail' => [
|
||||
'has_new' => $hasNewMail,
|
||||
'unread_count' => $unreadCount,
|
||||
'latest_id' => $latestMail['id'] ?? '',
|
||||
'new_mail' => $newMailPayload,
|
||||
],
|
||||
'jira' => [
|
||||
'has_new' => $hasNewJira,
|
||||
'newest_key' => $newestJiraKey,
|
||||
'new_issue' => $newJiraPayload,
|
||||
],
|
||||
'calendar' => [
|
||||
'has_changes' => $calendarChanged,
|
||||
'hash' => $calHash,
|
||||
'count' => count($todayEvents),
|
||||
],
|
||||
]);
|
||||
exit;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Microsoft Outlook 365 E-mail API Endpoint
|
||||
* Verwerkt ophalen van inbox, detailberichten, beantwoorden, doorsturen en 1-klik inplannen.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
require_once __DIR__ . '/../includes/boards.php';
|
||||
|
||||
$user = require_auth();
|
||||
$userId = (int)$user['id'];
|
||||
$db = get_db_connection();
|
||||
|
||||
// Geef het PHP sessieslot direct vrij zodat paginanavigatie nooit geblokkeerd wordt
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||
|
||||
try {
|
||||
// 0. Mail mappen ophalen
|
||||
if ($action === 'get_folders') {
|
||||
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
|
||||
$res = microsoft_fetch_mail_folders($userId, $forceRefresh);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 0b. Uitgeschakelde mappen opslaan
|
||||
if ($action === 'save_disabled_folders') {
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
$folders = $data['disabled_folders'] ?? ($_POST['disabled_folders'] ?? []);
|
||||
if (is_string($folders)) {
|
||||
$folders = json_decode($folders, true) ?: [];
|
||||
}
|
||||
if (!is_array($folders)) {
|
||||
$folders = [];
|
||||
}
|
||||
$ok = microsoft_save_disabled_folders($userId, $folders);
|
||||
echo json_encode(['success' => $ok, 'disabled_folders' => $folders]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1. Inbox of specifieke map berichten ophalen
|
||||
if ($action === 'get_folder_messages' || $action === 'get_inbox') {
|
||||
$folderId = trim($_GET['folder_id'] ?? ($_POST['folder_id'] ?? 'inbox'));
|
||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 30;
|
||||
$unreadOnly = !empty($_GET['unread_only']) && $_GET['unread_only'] === '1';
|
||||
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
|
||||
|
||||
$res = microsoft_fetch_folder_messages($userId, $folderId, $limit, $unreadOnly, $forceRefresh);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Enkel bericht detail ophalen
|
||||
if ($action === 'get_message') {
|
||||
$messageId = trim($_GET['id'] ?? ($_POST['id'] ?? ''));
|
||||
if (empty($messageId)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Geen bericht-ID opgegeven.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$detail = microsoft_fetch_message_detail($userId, $messageId);
|
||||
if (!$detail) {
|
||||
echo json_encode(['success' => false, 'error' => 'Bericht niet gevonden of geen toegang.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => $detail]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Markeer als gelezen of ongelezen
|
||||
if ($action === 'mark_read') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$messageId = trim($input['id'] ?? '');
|
||||
$isRead = isset($input['is_read']) ? (bool)$input['is_read'] : true;
|
||||
|
||||
if (empty($messageId)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Geen bericht-ID opgegeven.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ok = microsoft_mark_message_read($userId, $messageId, $isRead);
|
||||
echo json_encode(['success' => $ok]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. E-mail beantwoorden
|
||||
if ($action === 'reply') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$messageId = trim($input['id'] ?? '');
|
||||
$comment = trim($input['comment'] ?? '');
|
||||
$replyAll = !empty($input['reply_all']);
|
||||
|
||||
if (empty($messageId) || empty($comment)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Bericht-ID en reactietekst zijn verplicht.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$res = microsoft_reply_message($userId, $messageId, $comment, $replyAll);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. E-mail doorsturen
|
||||
if ($action === 'forward') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$messageId = trim($input['id'] ?? '');
|
||||
$toInput = $input['to'] ?? '';
|
||||
$comment = trim($input['comment'] ?? '');
|
||||
|
||||
if (empty($messageId) || empty($toInput)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Bericht-ID en minimaal één geadresseerde zijn verplicht.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$recipients = [];
|
||||
if (is_array($toInput)) {
|
||||
$recipients = $toInput;
|
||||
} else {
|
||||
$parts = explode(',', (string)$toInput);
|
||||
foreach ($parts as $p) {
|
||||
$p = trim($p);
|
||||
if (filter_var($p, FILTER_VALIDATE_EMAIL)) {
|
||||
$recipients[] = ['address' => $p, 'name' => $p];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($recipients)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Geen geldige e-mailadressen ingevoerd.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$res = microsoft_forward_message($userId, $messageId, $recipients, $comment);
|
||||
echo json_encode($res);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 6. E-mail direct inplannen in Fociz dagplanning
|
||||
if ($action === 'plan_email') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$messageId = trim($input['message_id'] ?? $input['mail_id'] ?? '');
|
||||
$subject = trim($input['title'] ?? $input['subject'] ?? 'E-mail actiepunt');
|
||||
$planDate = trim($input['plan_date'] ?? $input['date'] ?? date('Y-m-d'));
|
||||
$timeBlockStart = !empty($input['time_block_start']) ? trim($input['time_block_start']) : null;
|
||||
$durationMinutes = (int)($input['duration_minutes'] ?? 30);
|
||||
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
|
||||
$webLink = trim($input['web_link'] ?? 'https://outlook.office.com/mail/');
|
||||
$sender = trim($input['sender'] ?? $input['sender_name'] ?? '');
|
||||
|
||||
if (empty($subject)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Onderwerp is verplicht.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($durationMinutes <= 0) $durationMinutes = 30;
|
||||
|
||||
$timeBlockEnd = !empty($input['time_block_end']) ? trim($input['time_block_end']) : null;
|
||||
if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
|
||||
try {
|
||||
$dtEnd = new DateTime("{$planDate} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||
$dtEnd->modify("+{$durationMinutes} minutes");
|
||||
$timeBlockEnd = $dtEnd->format('H:i');
|
||||
} catch (Throwable $t) {}
|
||||
}
|
||||
|
||||
// Zorg dat daily_plan bestaat
|
||||
$stmtPlan = $db->prepare("
|
||||
INSERT INTO daily_plans (user_id, plan_date)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
|
||||
");
|
||||
$stmtPlan->execute([$userId, $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
|
||||
// Als er een custom titel is ingevoerd, gebruik die direct, anders maak een nette titel
|
||||
$taskTitle = $subject;
|
||||
if (isset($input['subject']) && !isset($input['title'])) {
|
||||
$taskTitle = "✉️ " . $subject;
|
||||
if (!empty($sender) && strpos($taskTitle, $sender) === false) {
|
||||
$taskTitle .= " (van " . $sender . ")";
|
||||
}
|
||||
}
|
||||
|
||||
$source = 'OUTLOOK_MAIL';
|
||||
$externalId = $messageId;
|
||||
$externalUrl = $webLink;
|
||||
$outlookSyncMsg = null;
|
||||
|
||||
// Automatisch synchroniseren naar Microsoft Outlook agenda
|
||||
if (function_exists('microsoft_create_event')) {
|
||||
try {
|
||||
$syncStartTime = !empty($timeBlockStart) ? $timeBlockStart : '09:00';
|
||||
$msResult = microsoft_create_event($userId, [
|
||||
'subject' => $taskTitle,
|
||||
'description' => "E-mail actiepunt gepland via Fociz ({$priority})\n\nAfzender: {$sender}\nWeblink: {$webLink}",
|
||||
'date' => $planDate,
|
||||
'start_time' => $syncStartTime,
|
||||
'end_time' => $timeBlockEnd,
|
||||
'duration_minutes' => $durationMinutes,
|
||||
]);
|
||||
|
||||
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||
$source = 'OUTLOOK';
|
||||
$externalId = $msResult['event_id'];
|
||||
$externalUrl = $msResult['web_link'] ?? ($webLink ?: 'https://outlook.office.com');
|
||||
$timeBlockStart = $msResult['start_time'] ?? $syncStartTime;
|
||||
$timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd;
|
||||
$outlookSyncMsg = 'Gesynchroniseerd naar Microsoft Outlook.';
|
||||
}
|
||||
} catch (Throwable $t) {
|
||||
error_log("Fout bij auto-sync Mail naar Outlook: " . $t->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$stmtTask = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, source, external_id, external_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmtTask->execute([
|
||||
$userId,
|
||||
$planId,
|
||||
$taskTitle,
|
||||
$priority,
|
||||
$timeBlockStart,
|
||||
$timeBlockEnd,
|
||||
$durationMinutes,
|
||||
$source,
|
||||
$externalId,
|
||||
$externalUrl
|
||||
]);
|
||||
$taskId = (int)$db->lastInsertId();
|
||||
|
||||
// Optioneel toevoegen aan Bord en Tijdlijn
|
||||
$boardSyncResult = null;
|
||||
$addToBoard = !empty($input['add_to_board']) && !empty($input['board_id']);
|
||||
if ($addToBoard) {
|
||||
$boardId = (int)$input['board_id'];
|
||||
$laneId = (int)($input['lane_id'] ?? 0);
|
||||
$scheduleOnTimeline = !empty($input['schedule_on_timeline']);
|
||||
$timelineStartDate = !empty($input['timeline_start_date']) ? trim($input['timeline_start_date']) : $planDate;
|
||||
$timelineEndDate = !empty($input['timeline_end_date']) ? trim($input['timeline_end_date']) : $timelineStartDate;
|
||||
|
||||
$mailDesc = "Afzender: " . ($sender ?: 'Onbekend');
|
||||
if (!empty($webLink)) {
|
||||
$mailDesc .= "\nWeblink: " . $webLink;
|
||||
}
|
||||
|
||||
$boardSyncResult = fociz_add_item_to_board_and_timeline($db, $userId, (($user['role'] ?? '') === 'ADMIN'), [
|
||||
'board_id' => $boardId,
|
||||
'lane_id' => $laneId,
|
||||
'title' => $taskTitle,
|
||||
'description' => $mailDesc,
|
||||
'priority' => $priority,
|
||||
'color' => !empty($input['board_color']) ? $input['board_color'] : 'blue',
|
||||
'schedule_on_timeline' => $scheduleOnTimeline,
|
||||
'start_date' => $timelineStartDate,
|
||||
'end_date' => $timelineEndDate,
|
||||
'start_time' => $timeBlockStart ?: '09:00',
|
||||
'end_time' => $timeBlockEnd ?: '10:00',
|
||||
]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'plan_date' => $planDate,
|
||||
'outlook_synced' => ($source === 'OUTLOOK'),
|
||||
'board_sync' => $boardSyncResult,
|
||||
'message' => "E-mail succesvol ingepland voor {$planDate}." . ($outlookSyncMsg ? " ({$outlookSyncMsg})" : "")
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => false, 'error' => 'Onbekende e-mailactie.']);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* AJAX API voor Persoonlijke Agenda Notificaties
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
|
||||
$user = require_auth();
|
||||
$db = get_db_connection();
|
||||
|
||||
// Geef het PHP sessieslot direct vrij zodat paginanavigatie nooit geblokkeerd wordt
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||
$today = date('Y-m-d');
|
||||
|
||||
try {
|
||||
// 1. Instellingen ophalen
|
||||
if ($action === 'get_settings') {
|
||||
$settings = get_user_notification_settings((int)$user['id']);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'settings' => $settings,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Instellingen opslaan
|
||||
if ($action === 'save_settings') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$saved = update_user_notification_settings((int)$user['id'], $input);
|
||||
$settings = get_user_notification_settings((int)$user['id']);
|
||||
|
||||
echo json_encode([
|
||||
'success' => $saved,
|
||||
'settings' => $settings,
|
||||
'message' => $saved ? 'Notificatievoorkeuren succesvol opgeslagen.' : 'Fout bij opslaan.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Actuele agenda-afspraken van vandaag ophalen voor notificatie-evaluatie
|
||||
if ($action === 'get_events') {
|
||||
$userId = (int)$user['id'];
|
||||
$settings = get_user_notification_settings($userId);
|
||||
|
||||
// Controleer of Outlook gekoppeld is
|
||||
$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
|
||||
$stmtInt->execute([$userId]);
|
||||
$outlookInt = $stmtInt->fetch();
|
||||
|
||||
$isLiveOutlook = !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode']);
|
||||
$events = [];
|
||||
|
||||
if ($isLiveOutlook) {
|
||||
$events = microsoft_fetch_today_events($userId, $today);
|
||||
}
|
||||
|
||||
// Als er geen live Outlook events zijn, haal ook taken met tijdblokken op uit Fociz dagplanning
|
||||
$stmtTasks = $db->prepare("
|
||||
SELECT t.id, t.title, t.time_block_start, t.time_block_end, t.source, t.external_url
|
||||
FROM tasks t
|
||||
LEFT JOIN daily_plans dp ON t.daily_plan_id = dp.id
|
||||
WHERE t.user_id = ? AND (dp.plan_date = ? OR t.time_block_start IS NOT NULL) AND t.is_completed = 0
|
||||
ORDER BY t.time_block_start ASC
|
||||
");
|
||||
$stmtTasks->execute([$userId, $today]);
|
||||
$tasks = $stmtTasks->fetchAll();
|
||||
|
||||
foreach ($tasks as $t) {
|
||||
if (!empty($t['time_block_start'])) {
|
||||
$events[] = [
|
||||
'id' => 'task_' . $t['id'],
|
||||
'subject' => $t['title'],
|
||||
'start' => $t['time_block_start'],
|
||||
'end' => $t['time_block_end'] ?: date('H:i', strtotime($t['time_block_start'] . ' +30 minutes')),
|
||||
'start_raw' => "{$today}T{$t['time_block_start']}:00",
|
||||
'location' => 'Fociz Dagplanning',
|
||||
'meeting_url' => $t['external_url'] ?? '/planner.php',
|
||||
'is_all_day' => false,
|
||||
'source' => 'FOCIZ_TASK',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Als er nog steeds geen afspraken zijn (en demo mode actief), lever sandbox items zodat er altijd getest kan worden
|
||||
if (empty($events) && (!empty($outlookInt['is_demo_mode']) || !$isLiveOutlook)) {
|
||||
$currentHour = (int)date('H');
|
||||
$nextHour = str_pad((string)(($currentHour + 1) % 24), 2, '0', STR_PAD_LEFT);
|
||||
$events = [
|
||||
[
|
||||
'id' => 'sandbox_meeting_1',
|
||||
'subject' => 'Focus Sync & Dagstart',
|
||||
'start' => "{$nextHour}:00",
|
||||
'end' => "{$nextHour}:30",
|
||||
'start_raw' => "{$today}T{$nextHour}:00:00",
|
||||
'location' => 'Microsoft Teams',
|
||||
'meeting_url' => 'https://teams.microsoft.com',
|
||||
'is_all_day' => false,
|
||||
'source' => 'SANDBOX',
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'server_time' => date('H:i:s'),
|
||||
'server_datetime' => date('Y-m-d H:i:s'),
|
||||
'server_timestamp'=> time(),
|
||||
'settings' => $settings,
|
||||
'events' => $events,
|
||||
'count' => count($events),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Ongeldige actie']);
|
||||
exit;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -53,30 +53,6 @@ try {
|
||||
$fields[] = "status = ?";
|
||||
$params[] = $status;
|
||||
}
|
||||
if (isset($data['quarter']) && in_array($data['quarter'], ['Q1', 'Q2', 'Q3', 'Q4'], true)) {
|
||||
$fields[] = "quarter = ?";
|
||||
$params[] = $data['quarter'];
|
||||
}
|
||||
if (isset($data['title']) && trim($data['title']) !== '') {
|
||||
$fields[] = "title = ?";
|
||||
$params[] = trim($data['title']);
|
||||
}
|
||||
if (isset($data['description'])) {
|
||||
$fields[] = "description = ?";
|
||||
$params[] = trim($data['description']);
|
||||
}
|
||||
if (isset($data['type'])) {
|
||||
$fields[] = "type = ?";
|
||||
$params[] = $data['type'];
|
||||
}
|
||||
if (isset($data['priority'])) {
|
||||
$fields[] = "priority = ?";
|
||||
$params[] = $data['priority'];
|
||||
}
|
||||
if (array_key_exists('due_date', $data)) {
|
||||
$fields[] = "due_date = ?";
|
||||
$params[] = !empty($data['due_date']) ? $data['due_date'] : null;
|
||||
}
|
||||
if ($progress !== null) {
|
||||
$fields[] = "progress = ?";
|
||||
$params[] = max(0, min(100, $progress));
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Session Guard API (Release v1.6)
|
||||
* Ondersteunt realtime session keepalive, inactiviteitstimeout status en veilige logout
|
||||
* Conform NIST SP 800-63B, OWASP ASVS v4.0 en ISO 27001
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/settings.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$currentUser = require_auth(true);
|
||||
$userId = (int)($currentUser['id'] ?? 0);
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? 'keepalive');
|
||||
$idleTimeout = get_session_idle_timeout();
|
||||
$now = time();
|
||||
|
||||
if ($action === 'keepalive' || $action === 'ping') {
|
||||
// Verleng de activiteitstimer van de sessie
|
||||
$_SESSION['last_activity'] = $now;
|
||||
$_SESSION['last_db_activity_update'] = $now;
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?");
|
||||
$stmtAct->execute([$userId]);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'remaining' => $idleTimeout,
|
||||
'timeout' => $idleTimeout,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'status') {
|
||||
$lastActivity = (int)($_SESSION['last_activity'] ?? $now);
|
||||
$elapsed = $now - $lastActivity;
|
||||
$remaining = max(0, $idleTimeout - $elapsed);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'remaining' => $remaining,
|
||||
'timeout' => $idleTimeout,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
fociz_terminate_session(session_id());
|
||||
fociz_log_audit($userId, 'LOGOUT_SESSION_GUARD', 'users', $userId, ['ip' => fociz_get_client_ip()], 'SUCCESS', $currentUser['email'] ?? null);
|
||||
} catch (Throwable $e) {
|
||||
error_log("Session guard logout error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
clear_day_session_cookie();
|
||||
$_SESSION = [];
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
@session_destroy();
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Succesvol uitgelogd.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Haal actieve apparaten en sessies op (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if ($action === 'get_active_sessions') {
|
||||
$sessions = fociz_get_user_active_sessions($userId, session_id());
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'sessions' => $sessions,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Beëindig alle overige actieve sessies (SOC 2 CC6.1)
|
||||
if ($action === 'revoke_other_sessions') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig CSRF beveiligingstoken.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$count = fociz_revoke_other_sessions($userId, session_id());
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'revoked_count' => $count,
|
||||
'message' => "Succesvol {$count} andere actieve sessie(s) op afstand beëindigd."
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Beëindig specifieke sessie op afstand
|
||||
if ($action === 'revoke_session') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig CSRF beveiligingstoken.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sessionDbId = (int)($_POST['session_id'] ?? 0);
|
||||
$ok = fociz_revoke_session_by_id($userId, $sessionDbId);
|
||||
echo json_encode([
|
||||
'success' => $ok,
|
||||
'message' => $ok ? 'Sessie succesvol op afstand beëindigd.' : 'Kan sessie niet intrekken.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Onbekende sessieactie.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Thema API Endpoint (Dark / Light / System)
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
$user = require_auth(true);
|
||||
$userId = (int)$user['id'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$currentTheme = get_user_theme($userId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'theme' => $currentTheme,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
$theme = $data['theme'] ?? ($_POST['theme'] ?? 'dark');
|
||||
if (!in_array($theme, ['dark', 'light', 'system'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig thema. Kies dark, light of system.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ok = update_user_theme($userId, $theme);
|
||||
if ($ok) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'theme' => $theme,
|
||||
'message' => 'Themavoorkeur succesvol opgeslagen.',
|
||||
]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Kon thema niet opslaan in database.']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Jira Webhook Receiver
|
||||
* Ontvangt webhook events van Atlassian Jira Cloud (issue_created, issue_updated, etc.)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../config/database.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Beveiligingscontrole: Verifieer geheime webhook token. Weiger toegang als secret ontbreekt.
|
||||
$configuredSecret = fociz_env('JIRA_WEBHOOK_SECRET', '');
|
||||
if (empty($configuredSecret)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Jira webhooks zijn niet ingeschakeld of geconfigureerd op deze server.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$incomingSecret = $_GET['secret'] ?? ($_SERVER['HTTP_X_ATLASSIAN_WEBHOOK_SECRET'] ?? '');
|
||||
if (empty($incomingSecret) || !hash_equals($configuredSecret, $incomingSecret)) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Onbevoegde webhook aanroep']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawPayload = file_get_contents('php://input');
|
||||
$data = json_decode($rawPayload, true);
|
||||
|
||||
if (!$data) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Ongeldige JSON payload']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$event = $data['webhookEvent'] ?? 'jira:unknown';
|
||||
$issueKey = $data['issue']['key'] ?? '';
|
||||
$summary = $data['issue']['fields']['summary'] ?? '';
|
||||
|
||||
// Sla event tijdelijk op of invalidate session cache
|
||||
error_log("Jira Webhook ontvangen: {$event} voor {$issueKey} - {$summary}");
|
||||
|
||||
echo json_encode([
|
||||
'received' => true,
|
||||
'event' => $event,
|
||||
'issue_key' => $issueKey,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
exit;
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $t->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Microsoft Graph Webhook Receiver
|
||||
* Ontvangt subscription notifications voor /me/mailFolders('inbox')/messages en /me/events
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../config/database.php';
|
||||
|
||||
// Validatie bij aanmaken van Graph subscription
|
||||
if (isset($_GET['validationToken'])) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
http_response_code(200);
|
||||
echo $_GET['validationToken'];
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if ($data && isset($data['value'])) {
|
||||
foreach ($data['value'] as $change) {
|
||||
$resource = $change['resource'] ?? '';
|
||||
$changeType = $change['changeType'] ?? '';
|
||||
error_log("Microsoft Graph Webhook: {$changeType} op {$resource}");
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['received' => true]);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#6366f1" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M21 29a19.88 19.88 0 0 1 39 0c-9.72-7.8-30.4-7.44-39 0Z" fill="#2B1607"/><path d="M20.58 29.54c-.1 0-.2-.03-.3-.09a.58.58 0 0 1-.24-.27.56.56 0 0 1-.02-.36c2.48-9.9 10.54-16.22 20.51-16.22 9.98 0 18.01 6.41 20.45 16.28.03.11.02.24-.02.35a.58.58 0 0 1-.23.27.62.62 0 0 1-.7 0c-9.4-7.44-30.24-7.44-39.1 0a.6.6 0 0 1-.35.04Zm19.74-6.8c7.07 0 14.22 1.6 19.11 4.77-1.15-3.98-3.62-7.5-7.03-10a19.88 19.88 0 0 0-11.83-3.82 19.94 19.94 0 0 0-11.82 3.8 19.02 19.02 0 0 0-7.06 9.97c4.58-3.14 11.6-4.71 18.63-4.71Z" fill="#000"/><path d="M65 41.02a4.86 4.86 0 0 1-1.42 3.5 5.04 5.04 0 0 1-7.08.08 4.9 4.9 0 0 1-1.5-3.47c0-3.94 3.16-5.81 4.84-12.13 1.53 6.15 5.12 7.66 5.16 12.02Z" fill="#4ABCF4"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M74.38 30a13 13 0 0 0 13-13v-.66C87.38 5.9 75.83-.87 68.45.1c-6.54.84-7.07 5.02-7.07 11.24V17a13.01 13.01 0 0 0 13 13Z" fill="#000"/><path d="M70.2 9.67a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.44 18.48a5.32 5.32 0 1 0 0-10.64 5.32 5.32 0 0 0 0 10.64Z" fill="#fff"/><path d="M13.37 30a13.01 13.01 0 0 1-13-13v-.66C.38 5.9 11.93-.87 19.32.1c6.56.84 7.07 5.02 7.07 11.24V17a13 13 0 0 1-13 13Z" fill="#000"/><path d="M9.22 9.67a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.46 18.48a5.32 5.32 0 1 0 0-10.64 5.32 5.32 0 0 0 0 10.64Z" fill="#fff"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#ec4899" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4c10.24 32.68 61.73 31.31 72 0A167.66 167.66 0 0 1 4 4Z" fill="#2B1607"/><path d="M39.68 29h-.64c-17.41-.24-31.54-9.95-36-24.74a1.02 1.02 0 0 1 .2-.95.98.98 0 0 1 .91-.28 165.35 165.35 0 0 0 71.72 0 .91.91 0 0 1 .9.3 1.04 1.04 0 0 1 .19.97C71.93 19.37 57.36 29 39.68 29ZM5.33 5.3c4.73 13.13 17.76 21.54 33.7 21.77C55.6 27.3 69.29 18.8 74.56 5.3a167.24 167.24 0 0 1-69.22 0Z" fill="#000"/><g style="mix-blend-mode:soft-light" opacity=".4"><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" fill="#422715"/><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" stroke="#000" stroke-width="1.58" stroke-miterlimit="10"/></g><path fill-rule="evenodd" clip-rule="evenodd" d="M40.83 45H38.1c-2.67 0-5.24-1.06-7.13-2.94a10 10 0 0 1-2.96-7.1V19.54c0-1.9 2.8-3.94 4.88-3.46a29.03 29.03 0 0 0 13.26 0C48 15.7 51 17.62 51 19.53v15.44a9.98 9.98 0 0 1-6.28 9.29c-1.23.5-2.55.75-3.89.74Z" fill="url(#mouthTongueOut-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M38.27 17c.95 3.75.33 13 1.13 13 .8 0 .53-9.01 1.37-13h-2.5Z" fill="#A00707"/><defs><linearGradient id="mouthTongueOut-a" x1="39.46" y1="45" x2="39.46" y2="16" gradientUnits="userSpaceOnUse"><stop stop-color="#EF0A0A"/><stop offset=".5" stop-color="#ED0A0A"/><stop offset=".67" stop-color="#E60A0A"/><stop offset=".8" stop-color="#DB0A0A"/><stop offset=".9" stop-color="#CA0A0A"/><stop offset=".99" stop-color="#B40A0A"/><stop offset="1" stop-color="#B10A0A"/></linearGradient></defs></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M74.46 30.06a13 13 0 0 1-13-13v-.66C61.46 5.95 73.01-.82 80.39.15c6.54.83 7.07 5.02 7.07 11.24v5.67a13.01 13.01 0 0 1-13 13Z" fill="#000"/><path d="M70.28 9.73a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.52 18.54a5.32 5.32 0 1 0 0-10.65 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M13.47 30.06a13.01 13.01 0 0 0 12.99-13v-.66C26.46 5.95 14.92-.82 7.53.15.97.98.46 5.17.46 11.39v5.67a13 13 0 0 0 13 13Z" fill="#000"/><path d="M9.3 9.73a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.54 18.54a5.32 5.32 0 1 0 0-10.65 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#10b981" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M40.54 12h-.75c-9.7.22-20.8 5.3-23.7 16.15a1.36 1.36 0 0 0 .44 1.55 1.41 1.41 0 0 0 2.26-.86c2.55-9.46 12.42-13.89 21.06-14.08 8.24-.16 19.04 3.84 22.46 14.57a1.47 1.47 0 0 0 1.65.55A1.44 1.44 0 0 0 65 28.5C61.85 18.69 52.3 12 40.54 12Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M74.38 30a13 13 0 0 0 13-13v-.66C87.38 5.9 75.83-.87 68.45.1c-6.54.84-7.07 5.02-7.07 11.24V17a13.01 13.01 0 0 0 13 13Z" fill="#000"/><path d="M70.2 9.67a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.44 18.48a5.32 5.32 0 1 0 0-10.64 5.32 5.32 0 0 0 0 10.64Z" fill="#fff"/><path d="M13.37 30a13.01 13.01 0 0 1-13-13v-.66C.38 5.9 11.93-.87 19.32.1c6.56.84 7.07 5.02 7.07 11.24V17a13 13 0 0 1-13 13Z" fill="#000"/><path d="M9.22 9.67a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.46 18.48a5.32 5.32 0 1 0 0-10.64 5.32 5.32 0 0 0 0 10.64Z" fill="#fff"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#8b5cf6" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M40.54 30h-.75c-9.7-.22-20.8-5.3-23.7-16.15a1.36 1.36 0 0 1 .44-1.55 1.41 1.41 0 0 1 2.26.86c2.55 9.46 12.42 13.89 21.06 14.08 8.24.16 19.04-3.84 22.46-14.57a1.47 1.47 0 0 1 1.65-.55A1.44 1.44 0 0 1 65 13.5C61.85 23.31 52.3 30 40.54 30Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.76 21.94c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 .49-1.92 1.7 1.7 0 0 1 2.76.9c.6 2.12 1.88 3.98 3.62 5.29a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.24.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.76 21.94c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.06 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.22 10.22 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.87 7.1 13.19 13.19 0 0 1-8.08 2.65Z" fill="#000"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#f59e0b" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M76 1v.49a35.13 35.13 0 0 1-.9 7.75 30.24 30.24 0 0 1-14.24 19.24C48.32 36 30.66 35.84 18.37 28A30.01 30.01 0 0 1 4.8 8.66 36.4 36.4 0 0 1 4 1c22.86 8.83 46.05 8.83 69.54.9L76 1Z" fill="#2B1607" stroke="#000" stroke-width="1.48" stroke-linecap="round" stroke-linejoin="round"/><path fill-rule="evenodd" clip-rule="evenodd" d="M76 1.48a33.97 33.97 0 0 1-.9 7.62C54.26 22.59 25.62 22.2 4.8 8.53 4.25 6.06 4 3.53 4 1c22.85 8.67 46.03 8.67 69.51.88l2.49-.4ZM62 28.49c-12.99 7.5-31.29 7.34-44-.49a134.2 134.2 0 0 0 44 .49Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M76.57.19c.27.19.43.5.43.83 0 11.3-4.64 19.83-11.62 25.51C58.42 32.2 49.18 35 40 35c-9.19 0-18.42-2.8-25.38-8.47C7.64 20.85 3 12.33 3 1.03A1.03 1.03 0 0 1 4.38.06c23.45 9.02 47.14 8.78 71.25 0 .32-.12.66-.08.94.12ZM5.07 2.5C5.45 12.44 9.7 19.9 15.9 24.94c6.54 5.32 15.3 8.01 24.1 8.01s17.56-2.69 24.1-8c6.2-5.06 10.45-12.53 10.83-22.46C51.36 10.77 28.1 11 5.07 2.5Z" fill="#000"/><path d="M24.01 36.05c4.5 5.16 25.03 5.5 30 0a68.03 68.03 0 0 1-30 0Z" fill="#000" style="mix-blend-mode:soft-light" opacity=".4"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M87.22 13.98c0-7.2-5.82-13.04-13-13.04s-13 5.84-13 13.04v3.92c0 7.2 5.82 13.04 13 13.04s13-5.83 13-13.04v-3.92Z" fill="#000"/><path d="M70 10.48a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.24 19.3a5.32 5.32 0 1 0 0-10.66 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M26.22 13.98c0-7.2-5.82-13.04-13-13.04s-13 5.84-13 13.04v3.92c0 7.2 5.82 13.04 13 13.04s13-5.83 13-13.04v-3.92Z" fill="#000"/><path d="M9 10.48A2.29 2.29 0 1 0 9 5.9a2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.24 19.3a5.32 5.32 0 1 0 0-10.66 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M84.33-5.7H65.45C58.63-5.7 53.1-.2 53.1 6.6v18.8c0 6.79 5.52 12.3 12.34 12.3h18.88c6.81 0 12.34-5.51 12.34-12.3V6.6c0-6.8-5.53-12.3-12.34-12.3Z" fill="url(#eyesGlasses-a)"/><path d="M21.3-5.7H2.42C-4.4-5.7-9.92-.2-9.92 6.6v18.8c0 6.79 5.52 12.3 12.34 12.3H21.3c6.81 0 12.34-5.51 12.34-12.3V6.6c0-6.8-5.53-12.3-12.34-12.3Z" fill="url(#eyesGlasses-b)"/><g fill="#000"><path d="M21.06 40.12H2.18A14.83 14.83 0 0 1-8.2 35.81a14.71 14.71 0 0 1-4.33-10.34V6.6c.02-3.87 1.58-7.59 4.34-10.34A14.85 14.85 0 0 1 2.18-8.06h18.88c3.92.02 7.66 1.58 10.42 4.35 2.76 2.76 4.31 6.5 4.31 10.4v18.7A14.62 14.62 0 0 1 26.71 39c-1.79.74-3.7 1.12-5.65 1.12ZM2.18-3.26A9.96 9.96 0 0 0-7 2.83a9.85 9.85 0 0 0-.76 3.78v18.8a9.85 9.85 0 0 0 9.9 9.86h18.92a9.93 9.93 0 0 0 9.9-9.86V6.6a9.87 9.87 0 0 0-9.9-9.86H2.18ZM84.33 40.12H65.46a14.83 14.83 0 0 1-10.39-4.31 14.71 14.71 0 0 1-4.33-10.34V6.6c.02-3.87 1.58-7.59 4.34-10.34a14.85 14.85 0 0 1 10.38-4.32h18.87c3.9.03 7.65 1.59 10.41 4.35s4.31 6.5 4.32 10.4v18.7c0 3.9-1.56 7.64-4.32 10.4a14.83 14.83 0 0 1-10.41 4.33ZM65.46-3.26a9.93 9.93 0 0 0-9.9 9.87v18.8a9.85 9.85 0 0 0 9.9 9.86h18.87a9.93 9.93 0 0 0 9.9-9.86V6.6a9.85 9.85 0 0 0-9.9-9.86H65.46Z"/><path d="M53.1 10.64H33.4v4.89h19.7v-4.89Z"/></g><defs><linearGradient id="eyesGlasses-a" x1="2332.67" y1="1561.82" x2="3621.21" y2="1561.82" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#969696" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity=".3"/></linearGradient><linearGradient id="eyesGlasses-b" x1="2269.64" y1="1561.82" x2="3558.18" y2="1561.82" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#969696" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity=".3"/></linearGradient></defs></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="100" ry="100" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#f43f5e" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4c10.24 32.68 61.73 31.31 72 0A167.66 167.66 0 0 1 4 4Z" fill="#2B1607"/><path d="M39.68 29h-.64c-17.41-.24-31.54-9.95-36-24.74a1.02 1.02 0 0 1 .2-.95.98.98 0 0 1 .91-.28 165.35 165.35 0 0 0 71.72 0 .91.91 0 0 1 .9.3 1.04 1.04 0 0 1 .19.97C71.93 19.37 57.36 29 39.68 29ZM5.33 5.3c4.73 13.13 17.76 21.54 33.7 21.77C55.6 27.3 69.29 18.8 74.56 5.3a167.24 167.24 0 0 1-69.22 0Z" fill="#000"/><g style="mix-blend-mode:soft-light" opacity=".4"><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" fill="#422715"/><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" stroke="#000" stroke-width="1.58" stroke-miterlimit="10"/></g><path fill-rule="evenodd" clip-rule="evenodd" d="M40.83 45H38.1c-2.67 0-5.24-1.06-7.13-2.94a10 10 0 0 1-2.96-7.1V19.54c0-1.9 2.8-3.94 4.88-3.46a29.03 29.03 0 0 0 13.26 0C48 15.7 51 17.62 51 19.53v15.44a9.98 9.98 0 0 1-6.28 9.29c-1.23.5-2.55.75-3.89.74Z" fill="url(#mouthTongueOut-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M38.27 17c.95 3.75.33 13 1.13 13 .8 0 .53-9.01 1.37-13h-2.5Z" fill="#A00707"/><defs><linearGradient id="mouthTongueOut-a" x1="39.46" y1="45" x2="39.46" y2="16" gradientUnits="userSpaceOnUse"><stop stop-color="#EF0A0A"/><stop offset=".5" stop-color="#ED0A0A"/><stop offset=".67" stop-color="#E60A0A"/><stop offset=".8" stop-color="#DB0A0A"/><stop offset=".9" stop-color="#CA0A0A"/><stop offset=".99" stop-color="#B40A0A"/><stop offset="1" stop-color="#B10A0A"/></linearGradient></defs></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.84 21.11c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 1.1-2.26 1.68 1.68 0 0 1 1.86.61c.14.2.24.4.3.64.6 2.11 1.87 3.97 3.61 5.28a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.23.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.84 21.11c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.05 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.21 10.21 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.86 7.1 13.19 13.19 0 0 1-8.1 2.65Z" fill="#000"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="90" ry="90" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#6366f1" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><mask id="sidesSquareAssymetric-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="10" y="8" width="165" height="60"><g fill="#fff"><rect x="10" y="31" width="36" height="30" rx="4"/><rect x="20" y="15" width="26" height="30" rx="4"/><rect x="139" y="23" width="36" height="30" rx="4"/><rect x="134" y="8" width="36" height="60" rx="4"/></g></mask><g mask="url(#sidesSquareAssymetric-a)"><path d="M0 0h180v76H0V0Z" fill="#d81b60"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#000" fill-opacity=".1" d="M0 47h180v29H0z"/><circle cx="161" cy="20" r="5" fill="#fff" fill-opacity=".6"/><circle cx="161" cy="36" r="5" fill="#fff" fill-opacity=".6"/></g></g><g transform="translate(41)"><mask id="topPyramid-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="8" width="64" height="44"><path fill-rule="evenodd" clip-rule="evenodd" d="m50 8 32 44H18L50 8Z" fill="#fff"/></mask><g mask="url(#topPyramid-a)"><path d="M0 0h100v52H0V0Z" fill="#d81b60"/><path d="M0 0h100v52H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#fff" fill-opacity=".8" d="M50 4h30v48H50z"/></g></g><g transform="translate(25 44)"><mask id="faceSquare04-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 102V68.85a40 40 0 0 1 2.28-13.31L19.76 12A18 18 0 0 1 36.74 0h56.52a18 18 0 0 1 16.98 12l17.48 43.54A40 40 0 0 1 130 68.85V102a18 18 0 0 1-18 18H18a18 18 0 0 1-18-18Z" fill="#fff"/></mask><g mask="url(#faceSquare04-a)"><path d="M-2-2h134v124H-2V-2Z" fill="#d81b60"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><path d="M27.05 8.44a2 2 0 1 1 3.9-.88C31.72 10.96 34.4 13 38 13c3.6 0 6.28-2.04 7.05-5.44a2 2 0 1 1 3.9.88C47.75 13.7 43.43 17 38 17s-9.76-3.3-10.95-8.56Z" fill="#000" fill-opacity=".6"/></g><g transform="translate(38 76)"><path d="m25 27.2 5.5 5.5c.5.4 1.2.4 1.6 0l1.6-1.6c.4-.5.4-1.2 0-1.6L28.2 24l5.5-5.5c.4-.5.4-1.2 0-1.6l-1.6-1.6c-.5-.4-1.2-.4-1.6 0L25 20.8l-5.5-5.5c-.5-.4-1.2-.4-1.6 0l-1.6 1.6c-.4.4-.4 1.1 0 1.6l5.5 5.5-5.5 5.5c-.4.5-.4 1.2 0 1.6l1.6 1.6c.5.4 1.2.4 1.6 0l5.5-5.5ZM79 27.2l5.5 5.5c.5.4 1.2.4 1.6 0l1.6-1.6c.4-.5.4-1.2 0-1.6L82.2 24l5.5-5.5c.4-.5.4-1.2 0-1.6l-1.6-1.6c-.5-.4-1.2-.4-1.6 0L79 20.8l-5.5-5.5c-.5-.4-1.2-.4-1.6 0l-1.6 1.6c-.4.4-.4 1.1 0 1.6l5.5 5.5-5.5 5.5c-.4.5-.4 1.2 0 1.6l1.6 1.6c.5.4 1.2.4 1.6 0l5.5-5.5Z" fill="#000" fill-opacity=".8"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="90" ry="90" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#10b981" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><g opacity=".9" stroke="#2A3544"><ellipse cx="32.5" cy="23" rx="16.5" ry="18" stroke-width="6"/><path d="M29.51 36.76c-7.4 4.29-17 1.55-21.42-6.1" stroke-width="4"/><ellipse cx="28.5" cy="52.5" rx="16.5" ry="14.5" stroke-width="4"/></g><g opacity=".9" stroke="#2A3544"><path d="M168.6 60.42c-4.27-7.41-13.95-9.84-21.6-5.42" stroke-width="4"/><ellipse cx="148.5" cy="22.5" rx="16.5" ry="15.5" stroke-width="6"/></g><mask id="sidesCables02-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="21" y="0" width="138" height="72"><g fill="#fff"><rect x="21" y="27" width="16" height="22" rx="2"/><rect x="22" y="60" width="16" height="12" rx="2"/><rect x="143" y="42" width="16" height="22" rx="2"/><rect x="143" width="16" height="22" rx="2"/></g></mask><g mask="url(#sidesCables02-a)"><path d="M0 0h180v76H0V0Z" fill="#e53935"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/></g></g><g transform="translate(41)"><g filter="url(#topGlowingBulb01-a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 24A16 16 0 0 1 48 8h4a16 16 0 0 1 16 16v8a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-8Z" fill="#fff" fill-opacity=".3"/></g><path d="M49 11.5c4.93 0 9.37 2.13 12.44 5.52" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="m49.83 29-9-9L38 22.83l10 10V40h4v-7.03l10.14-10.14L59.31 20l-9 9h-.48Z" fill="#fff" fill-opacity=".8"/><rect x="22" y="40" width="56" height="12" rx="1" fill="#48494B"/><defs><filter id="topGlowingBulb01-a" x="24" y="0" width="52" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="4"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_617_621"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_617_621" result="shape"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="2"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/><feBlend in2="shape" result="effect2_innerShadow_617_621"/></filter></defs></g><g transform="translate(25 44)"><mask id="faceSquare02-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><path d="M0 12A12 12 0 0 1 12 0h106a12 12 0 0 1 12 12v70a38 38 0 0 1-38 38H38A38 38 0 0 1 0 82V12Z" fill="#fff"/></mask><g mask="url(#faceSquare02-a)"><path d="M-2-2h134v124H-2V-2Z" fill="#e53935"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><g fill="#000" fill-opacity=".6"><rect x="28" y="10" width="6" height="14" rx="2"/><rect x="14" y="10" width="6" height="14" rx="2"/><rect x="42" y="10" width="6" height="14" rx="2"/><rect x="56" y="10" width="6" height="14" rx="2"/></g></g><g transform="translate(38 76)"><g fill-rule="evenodd" clip-rule="evenodd"><path d="M53 0c34.75 0 49 17.47 49 31 0 13.53-19.59 17-49 17-29.05 0-51-3.47-51-17S17.11 0 53 0Z" fill="#000" fill-opacity=".8"/><path d="M28.82 34.65c-6.53-1.35-11.24-6.34-10.52-11.14.72-4.79 6.6-7.58 13.12-6.23 6.53 1.36 11.24 6.35 10.52 11.15-.72 4.8-6.6 7.59-13.12 6.23ZM75.42 34.65c-6.52 1.36-12.4-1.43-13.12-6.23-.72-4.8 4-9.8 10.52-11.15 6.52-1.35 12.4 1.44 13.12 6.24.72 4.81-4 9.8-10.52 11.15Z" fill="#25A6F5"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="90" ry="90" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#8b5cf6" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><mask id="sidesSquareAssymetric-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="10" y="8" width="165" height="60"><g fill="#fff"><rect x="10" y="31" width="36" height="30" rx="4"/><rect x="20" y="15" width="26" height="30" rx="4"/><rect x="139" y="23" width="36" height="30" rx="4"/><rect x="134" y="8" width="36" height="60" rx="4"/></g></mask><g mask="url(#sidesSquareAssymetric-a)"><path d="M0 0h180v76H0V0Z" fill="#fdd835"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#000" fill-opacity=".1" d="M0 47h180v29H0z"/><circle cx="161" cy="20" r="5" fill="#fff" fill-opacity=".6"/><circle cx="161" cy="36" r="5" fill="#fff" fill-opacity=".6"/></g></g><g transform="translate(41)"><mask id="topHorns-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="8" y="12" width="84" height="40"><g fill="#fff"><path d="M8 40h26v12H8z"/><path transform="matrix(-1 0 0 1 92 40)" d="M0 0h26v12H0z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M16.52 13.74c0 7.84 5.39 20.71 13.72 27.04H11.36S7.84 22.66 13.43 14.1c.9-1.38 3.1-1.42 3.1-.36ZM84 14c.66 7.04-5.77 20.62-14 27h19s3.14-18.26-2-27c-1-1.7-3.14-1.45-3 0Z"/></g></mask><g mask="url(#topHorns-a)"><path d="M0 0h100v52H0V0Z" fill="#fdd835"/><path d="M0 0h100v52H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#000" fill-opacity=".4" d="M0 40h100v12H0z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M15.46 13h16.1v27H20.83c-7.45-7.85-5.36-27-5.36-27ZM84.82 13h7.75v27H81.82c5.75-7.8 3-27 3-27Z" fill="#fff" fill-opacity=".4"/></g></g><g transform="translate(25 44)"><mask id="faceSquare01-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><rect width="130" height="120" rx="18" fill="#fff"/></mask><g mask="url(#faceSquare01-a)"><path d="M-2-2h134v124H-2V-2Z" fill="#fdd835"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><rect x="4" y="4" width="68" height="24" rx="5" fill="#000" fill-opacity=".2"/><rect x="8" y="8" width="60" height="16" rx="2" fill="#000" fill-opacity=".8"/><path d="M9 17h11l2-4 3 7 4-8 2 9 3-11 3 10 3-3h15l3-4 2 7 3-3h4" stroke="#4EFAC9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></g><g transform="translate(38 76)"><g fill="#fff"><circle cx="24" cy="30" r="6"/><circle cx="80" cy="30" r="6"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="90" ry="90" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#06b6d4" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><mask id="sidesSquareAssymetric-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="10" y="8" width="165" height="60"><g fill="#fff"><rect x="10" y="31" width="36" height="30" rx="4"/><rect x="20" y="15" width="26" height="30" rx="4"/><rect x="139" y="23" width="36" height="30" rx="4"/><rect x="134" y="8" width="36" height="60" rx="4"/></g></mask><g mask="url(#sidesSquareAssymetric-a)"><path d="M0 0h180v76H0V0Z" fill="#1e88e5"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/><path fill="#000" fill-opacity=".1" d="M0 47h180v29H0z"/><circle cx="161" cy="20" r="5" fill="#fff" fill-opacity=".6"/><circle cx="161" cy="36" r="5" fill="#fff" fill-opacity=".6"/></g></g><g transform="translate(41)"><mask id="topAntenna-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="38" y="5" width="24" height="47"><path d="M38 38c0-1.1.9-2 2-2h20a2 2 0 0 1 2 2v14H38V38ZM48 5h4v31h-4z" fill="#fff"/></mask><g mask="url(#topAntenna-a)"><path d="M0 0h100v52H0V0Z" fill="#1e88e5"/><path d="M0 3h100v52H0V3Z" fill="#fff" fill-opacity=".3"/><path fill="#fff" fill-opacity=".2" d="M38 36h24v16H38z"/></g><circle cx="50" cy="8" r="8" fill="#FFE65C"/><circle cx="53" cy="5" r="3" fill="#fff"/></g><g transform="translate(25 44)"><mask id="faceRound01-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><path fill-rule="evenodd" clip-rule="evenodd" d="M66 0c58.35 0 64 40.69 64 78 0 33.31-25.47 42-64 42-37.46 0-66-8.69-66-42C0 40.69 7.65 0 66 0Z" fill="#fff"/></mask><g mask="url(#faceRound01-a)"><path d="M-4-2h138v124H-4V-2Z" fill="#1e88e5"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><g fill="#000" fill-opacity=".6"><rect x="28" y="10" width="6" height="14" rx="2"/><rect x="14" y="10" width="6" height="14" rx="2"/><rect x="42" y="10" width="6" height="14" rx="2"/><rect x="56" y="10" width="6" height="14" rx="2"/></g></g><g transform="translate(38 76)"><rect x="7" y="16" width="91" height="16" rx="4" fill="#000" fill-opacity=".8"/><mask id="eyesRobocop-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="7" y="16" width="91" height="16"><rect x="7" y="16" width="91" height="16" rx="4" fill="#fff"/></mask><g mask="url(#eyesRobocop-a)" fill-rule="evenodd" clip-rule="evenodd" fill="#fff" fill-opacity=".8"><path d="M76 7h18L82 37H64L76 7ZM52 7h9L49 37h-9L52 7Z"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="focizGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="50%" stop-color="#4f46e5"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<filter id="focizShadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4f46e5" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Achtergrond met afgeronde hoeken (superellipse / squircl) -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#focizGrad)" filter="url(#focizShadow)"/>
|
||||
<!-- Subtiele binnenrand highlight -->
|
||||
<rect x="3" y="3" width="58" height="58" rx="15" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- Karakteristieke Fociz 'F' -->
|
||||
<path d="M22 17 H43 C44.6569 17 46 18.3431 46 20 C46 21.6569 44.6569 23 43 23 H28 V29 H39 C40.6569 29 42 30.3431 42 32 C42 33.6569 40.6569 35 39 35 H28 V44 C28 45.6569 26.6569 47 25 47 C23.3431 47 22 45.6569 22 44 V17 Z" fill="#ffffff"/>
|
||||
<!-- Subtiele focus stip / accent -->
|
||||
<circle cx="43" cy="43" r="3.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,21 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="focizGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="50%" stop-color="#4f46e5"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<filter id="focizShadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4f46e5" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Achtergrond met afgeronde hoeken (superellipse / squircl) -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#focizGrad)" filter="url(#focizShadow)"/>
|
||||
<!-- Subtiele binnenrand highlight -->
|
||||
<rect x="3" y="3" width="58" height="58" rx="15" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- Karakteristieke Fociz 'F' -->
|
||||
<path d="M22 17 H43 C44.6569 17 46 18.3431 46 20 C46 21.6569 44.6569 23 43 23 H28 V29 H39 C40.6569 29 42 30.3431 42 32 C42 33.6569 40.6569 35 39 35 H28 V44 C28 45.6569 26.6569 47 25 47 C23.3431 47 22 45.6569 22 44 V17 Z" fill="#ffffff"/>
|
||||
<!-- Subtiele focus stip / accent -->
|
||||
<circle cx="43" cy="43" r="3.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -1,541 +0,0 @@
|
||||
/**
|
||||
* Fociz Browser Push Notificaties Engine
|
||||
* Monitort actuele agenda-afspraken en stuurt browser notificaties op basis van persoonlijk profiel.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
// 1. Globale state
|
||||
window.focizNotifSettings = {
|
||||
enabled: 1,
|
||||
lead_time_minutes: 5,
|
||||
repeat_count: 2,
|
||||
repeat_interval_minutes: 2,
|
||||
sound_enabled: 1
|
||||
};
|
||||
|
||||
let cachedEvents = [];
|
||||
let isFetchingEvents = false;
|
||||
|
||||
// 2. Service Worker Registratie
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||
.then(reg => {
|
||||
// Service worker geregistreerd
|
||||
})
|
||||
.catch(err => {
|
||||
// Stille fallback indien niet op HTTPS of lokaal
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Web Audio API Chime Synthesizer
|
||||
window.playNotificationChime = function () {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContext) return;
|
||||
const ctx = new AudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Noot 1: 587.33 Hz (D5)
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.type = 'sine';
|
||||
osc1.frequency.setValueAtTime(587.33, now);
|
||||
gain1.gain.setValueAtTime(0.2, now);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(ctx.destination);
|
||||
osc1.start(now);
|
||||
osc1.stop(now + 0.35);
|
||||
|
||||
// Noot 2: 880 Hz (A5)
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.setValueAtTime(880.0, now + 0.12);
|
||||
gain2.gain.setValueAtTime(0.25, now + 0.12);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.55);
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(ctx.destination);
|
||||
osc2.start(now + 0.12);
|
||||
osc2.stop(now + 0.55);
|
||||
} catch (e) {
|
||||
console.warn('Audio chime kon niet afspelen:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 4. In-App Toast Banner
|
||||
function showInAppToast(title, body, meetingUrl) {
|
||||
let container = document.getElementById('focizToastContainer');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'focizToastContainer';
|
||||
container.className = 'fixed bottom-5 right-5 z-[9999] flex flex-col space-y-3 max-w-sm pointer-events-none';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'pointer-events-auto bg-slate-900/95 border border-blue-500/40 text-white p-4 rounded-2xl shadow-2xl backdrop-blur-md flex items-start space-x-3 transform translate-y-4 opacity-0 transition duration-300 ease-out';
|
||||
|
||||
const iconDiv = document.createElement('div');
|
||||
iconDiv.className = 'w-9 h-9 rounded-xl bg-blue-500/20 text-blue-400 flex items-center justify-center flex-shrink-0 mt-0.5 border border-blue-500/30';
|
||||
iconDiv.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke-width="2"/><line x1="16" y1="2" x2="16" y2="6" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="6" stroke-width="2"/><line x1="3" y1="10" x2="21" y2="10" stroke-width="2"/></svg>';
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'flex-1 min-w-0';
|
||||
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'text-xs font-bold text-white truncate';
|
||||
titleEl.textContent = title;
|
||||
|
||||
const bodyEl = document.createElement('div');
|
||||
bodyEl.className = 'text-[11px] text-slate-300 mt-0.5 leading-snug';
|
||||
bodyEl.textContent = body;
|
||||
|
||||
contentDiv.appendChild(titleEl);
|
||||
contentDiv.appendChild(bodyEl);
|
||||
|
||||
if (meetingUrl) {
|
||||
const btn = document.createElement('a');
|
||||
btn.href = meetingUrl;
|
||||
btn.target = '_blank';
|
||||
btn.className = 'inline-flex items-center space-x-1 mt-2 px-2.5 py-1 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold transition';
|
||||
btn.innerHTML = '<span>Direct Deelnemen</span> →';
|
||||
contentDiv.appendChild(btn);
|
||||
}
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'text-slate-500 hover:text-white transition -mr-1 -mt-1 p-1';
|
||||
closeBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>';
|
||||
closeBtn.onclick = () => {
|
||||
toast.classList.add('opacity-0', 'translate-y-2');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
};
|
||||
|
||||
toast.appendChild(iconDiv);
|
||||
toast.appendChild(contentDiv);
|
||||
toast.appendChild(closeBtn);
|
||||
container.appendChild(toast);
|
||||
|
||||
// Animaties
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.remove('opacity-0', 'translate-y-4');
|
||||
});
|
||||
|
||||
// Automatisch verdwijnen na 10 seconden
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.classList.add('opacity-0', 'translate-y-2');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// 5. Browser Notificatie Verzenden
|
||||
window.sendBrowserNotification = async function (title, body, meetingUrl, tag) {
|
||||
if (!window.focizNotifSettings.enabled) return;
|
||||
|
||||
// Geluidssignaal
|
||||
if (window.focizNotifSettings.sound_enabled) {
|
||||
playNotificationChime();
|
||||
}
|
||||
|
||||
// In-app toast banner
|
||||
showInAppToast(title, body, meetingUrl);
|
||||
|
||||
// HTML5 Browser Push Notificatie
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
try {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
if (reg && reg.showNotification) {
|
||||
reg.showNotification(title, {
|
||||
body: body,
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
tag: tag || 'fociz-' + Date.now(),
|
||||
data: { url: meetingUrl || '/planner.php' },
|
||||
requireInteraction: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const n = new Notification(title, {
|
||||
body: body,
|
||||
icon: '/favicon.ico',
|
||||
tag: tag || 'fociz-' + Date.now()
|
||||
});
|
||||
n.onclick = function () {
|
||||
window.focus();
|
||||
if (meetingUrl) {
|
||||
window.open(meetingUrl, '_blank');
|
||||
}
|
||||
n.close();
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('Desktop notificatie kon niet worden getoond:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Evaluatie van agenda-afspraken
|
||||
function evaluateEvents() {
|
||||
if (!window.focizNotifSettings.enabled) return;
|
||||
if (!cachedEvents || cachedEvents.length === 0) return;
|
||||
|
||||
const now = Date.now();
|
||||
const leadMs = (parseInt(window.focizNotifSettings.lead_time_minutes) || 5) * 60 * 1000;
|
||||
const repeatCount = Math.max(1, parseInt(window.focizNotifSettings.repeat_count) || 2);
|
||||
const intervalMs = (parseInt(window.focizNotifSettings.repeat_interval_minutes) || 2) * 60 * 1000;
|
||||
|
||||
cachedEvents.forEach(evt => {
|
||||
if (!evt.start_raw && !evt.start) return;
|
||||
|
||||
// Bepaal starttijd in milliseconds
|
||||
let startMs = 0;
|
||||
if (evt.start_raw) {
|
||||
startMs = new Date(evt.start_raw).getTime();
|
||||
} else if (evt.start) {
|
||||
const parts = evt.start.split(':');
|
||||
const today = new Date();
|
||||
today.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), 0, 0);
|
||||
startMs = today.getTime();
|
||||
}
|
||||
|
||||
if (!startMs || isNaN(startMs)) return;
|
||||
|
||||
// Check herhalingen
|
||||
for (let i = 0; i < repeatCount; i++) {
|
||||
// Eerste notificatie: start - leadMs
|
||||
// Volgende notificaties: T_0 + i * intervalMs (tot maximaal starttijd)
|
||||
let triggerMs = startMs - leadMs + (i * intervalMs);
|
||||
if (triggerMs > startMs) {
|
||||
triggerMs = startMs;
|
||||
}
|
||||
|
||||
// Venster: notificatie is nu actueel (tussen triggerMs en 4 minuten na de start)
|
||||
const isDue = (now >= triggerMs) && (now <= startMs + (4 * 60 * 1000));
|
||||
if (isDue) {
|
||||
const storageKey = `fociz_notif_${evt.id}_rep_${i}`;
|
||||
const alreadySent = localStorage.getItem(storageKey);
|
||||
|
||||
if (!alreadySent) {
|
||||
localStorage.setItem(storageKey, String(now));
|
||||
|
||||
const minutesLeft = Math.max(0, Math.round((startMs - now) / 60000));
|
||||
let notifTitle = '';
|
||||
let notifBody = '';
|
||||
|
||||
if (now >= startMs) {
|
||||
notifTitle = `🚀 Nu gestart: ${evt.subject}`;
|
||||
notifBody = `Afspraak is begonnen (${evt.start}) • ${evt.location || 'Teams'}`;
|
||||
} else if (minutesLeft === 0) {
|
||||
notifTitle = `⚡ Begint zo: ${evt.subject}`;
|
||||
notifBody = `Start om ${evt.start} • ${evt.location || 'Teams'}`;
|
||||
} else {
|
||||
notifTitle = `📅 Over ${minutesLeft} min: ${evt.subject}`;
|
||||
notifBody = `Aanvang ${evt.start} - ${evt.end} • ${evt.location || 'Teams'}`;
|
||||
}
|
||||
|
||||
sendBrowserNotification(notifTitle, notifBody, evt.meeting_url, `fociz-event-${evt.id}-${i}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Data ophalen van backend
|
||||
async function refreshEvents() {
|
||||
if (isFetchingEvents) return;
|
||||
isFetchingEvents = true;
|
||||
try {
|
||||
const res = await fetch('/api/notifications.php?action=get_events', {
|
||||
cache: 'no-store',
|
||||
headers: { 'X-Background-Poll': '1' }
|
||||
});
|
||||
if (res.status === 401) {
|
||||
console.warn('Notificatiesessie niet geauthenticeerd.');
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.success) {
|
||||
cachedEvents = data.events || [];
|
||||
if (data.settings) {
|
||||
window.focizNotifSettings = data.settings;
|
||||
updateDotStatus();
|
||||
}
|
||||
evaluateEvents();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Netwerkfout stil opvangen
|
||||
} finally {
|
||||
isFetchingEvents = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Dot indicator bijwerken op de bel in de header
|
||||
function updateDotStatus() {
|
||||
const dot = document.getElementById('focizNotificationDot');
|
||||
if (!dot) return;
|
||||
if ('Notification' in window && Notification.permission === 'granted' && window.focizNotifSettings.enabled) {
|
||||
dot.classList.remove('hidden');
|
||||
} else {
|
||||
dot.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Modal helpers
|
||||
window.openNotificationModal = function () {
|
||||
const modal = document.getElementById('focizNotificationModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Vul actuele waarden in formulier
|
||||
const s = window.focizNotifSettings;
|
||||
const enabledCheckbox = document.getElementById('notifEnabledInput');
|
||||
const leadTimeSelect = document.getElementById('notifLeadTimeSelect');
|
||||
const repeatCountSelect = document.getElementById('notifRepeatCountSelect');
|
||||
const intervalSelect = document.getElementById('notifIntervalSelect');
|
||||
const soundCheckbox = document.getElementById('notifSoundInput');
|
||||
|
||||
if (enabledCheckbox) enabledCheckbox.checked = !!parseInt(s.enabled);
|
||||
if (leadTimeSelect) leadTimeSelect.value = String(s.lead_time_minutes);
|
||||
if (repeatCountSelect) repeatCountSelect.value = String(s.repeat_count);
|
||||
if (intervalSelect) intervalSelect.value = String(s.repeat_interval_minutes);
|
||||
if (soundCheckbox) soundCheckbox.checked = !!parseInt(s.sound_enabled);
|
||||
|
||||
updatePermissionBanner();
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
};
|
||||
|
||||
window.closeNotificationModal = function () {
|
||||
const modal = document.getElementById('focizNotificationModal');
|
||||
if (!modal) return;
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
};
|
||||
|
||||
function updatePermissionBanner() {
|
||||
const grantedDiv = document.getElementById('notifPermGranted');
|
||||
const requestDiv = document.getElementById('notifPermRequest');
|
||||
const deniedDiv = document.getElementById('notifPermDenied');
|
||||
|
||||
if (!('Notification' in window)) {
|
||||
if (grantedDiv) grantedDiv.classList.add('hidden');
|
||||
if (requestDiv) requestDiv.classList.add('hidden');
|
||||
if (deniedDiv) deniedDiv.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const perm = Notification.permission;
|
||||
if (perm === 'granted') {
|
||||
if (grantedDiv) grantedDiv.classList.remove('hidden');
|
||||
if (requestDiv) requestDiv.classList.add('hidden');
|
||||
if (deniedDiv) deniedDiv.classList.add('hidden');
|
||||
} else if (perm === 'denied') {
|
||||
if (grantedDiv) grantedDiv.classList.add('hidden');
|
||||
if (requestDiv) requestDiv.classList.add('hidden');
|
||||
if (deniedDiv) deniedDiv.classList.remove('hidden');
|
||||
} else {
|
||||
if (grantedDiv) grantedDiv.classList.add('hidden');
|
||||
if (requestDiv) requestDiv.classList.remove('hidden');
|
||||
if (deniedDiv) deniedDiv.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
window.focizRequestPermission = async function () {
|
||||
if (!('Notification' in window)) {
|
||||
alert('Uw browser ondersteunt helaas geen HTML5 Web Notificaties.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await Notification.requestPermission();
|
||||
updatePermissionBanner();
|
||||
updateDotStatus();
|
||||
if (res === 'granted') {
|
||||
sendBrowserNotification('✅ Notificaties Ingeschakeld!', 'Fociz stuurt u nu pushberichten bij actuele afspraken.', null, 'fociz-test-perm');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
window.focizTestNotification = function () {
|
||||
sendBrowserNotification('🔔 Test Notificatie Fociz', 'Dit is hoe uw agenda-herinnering eruit ziet. Het geluid en de melding werken perfect!', '/planner.php', 'fociz-test-manual');
|
||||
};
|
||||
|
||||
window.saveNotificationPreferences = async function (e) {
|
||||
if (e) e.preventDefault();
|
||||
const btn = document.getElementById('saveNotifBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
const payload = {
|
||||
enabled: document.getElementById('notifEnabledInput').checked ? 1 : 0,
|
||||
lead_time_minutes: parseInt(document.getElementById('notifLeadTimeSelect').value, 10),
|
||||
repeat_count: parseInt(document.getElementById('notifRepeatCountSelect').value, 10),
|
||||
repeat_interval_minutes: parseInt(document.getElementById('notifIntervalSelect').value, 10),
|
||||
sound_enabled: document.getElementById('notifSoundInput').checked ? 1 : 0
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/notifications.php?action=save_settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.success) {
|
||||
window.focizNotifSettings = data.settings;
|
||||
updateDotStatus();
|
||||
const statusMsg = document.getElementById('notifSaveStatus');
|
||||
if (statusMsg) {
|
||||
statusMsg.textContent = 'Voorkeuren succesvol opgeslagen!';
|
||||
statusMsg.classList.remove('hidden');
|
||||
setTimeout(() => statusMsg.classList.add('hidden'), 3000);
|
||||
}
|
||||
setTimeout(() => closeNotificationModal(), 1000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Fout bij opslaan van voorkeuren: ' + err.message);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 7b. Real-time Live Sync (Mail, Jira & Agenda)
|
||||
let lastKnownMailId = localStorage.getItem('fociz_last_mail_id') || '';
|
||||
let lastKnownJiraKey = localStorage.getItem('fociz_last_jira_key') || '';
|
||||
let lastCalendarHash = '';
|
||||
let isLiveSyncRunning = false;
|
||||
let isInitialLiveSync = true;
|
||||
|
||||
async function checkLiveSync() {
|
||||
if (isLiveSyncRunning) return;
|
||||
isLiveSyncRunning = true;
|
||||
try {
|
||||
const url = `/api/live_sync.php?last_mail_id=${encodeURIComponent(lastKnownMailId)}&last_jira_key=${encodeURIComponent(lastKnownJiraKey)}&calendar_hash=${encodeURIComponent(lastCalendarHash)}`;
|
||||
const res = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: { 'X-Background-Poll': '1' }
|
||||
});
|
||||
if (res.status === 401) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.success) {
|
||||
// Update mail badge in navigatie (desktop & mobiel)
|
||||
const mailBadge = document.getElementById('navMailBadge');
|
||||
const mailBadgeMobile = document.getElementById('navMailBadgeMobile');
|
||||
if (typeof data.mail?.unread_count !== 'undefined') {
|
||||
const count = parseInt(data.mail.unread_count, 10) || 0;
|
||||
const badgeText = count > 99 ? '99+' : count;
|
||||
[mailBadge, mailBadgeMobile].forEach(el => {
|
||||
if (!el) return;
|
||||
if (count > 0) {
|
||||
el.innerText = badgeText;
|
||||
el.classList.remove('hidden');
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// E-mail melding
|
||||
if (!isInitialLiveSync && data.mail?.has_new && data.mail.new_mail) {
|
||||
const m = data.mail.new_mail;
|
||||
sendBrowserNotification(
|
||||
`✉️ Nieuwe mail: ${m.from_name}`,
|
||||
`${m.subject} • ${m.received_human || 'Zojuist'}`,
|
||||
m.web_link || '/mail.php',
|
||||
`fociz-mail-${m.id}`
|
||||
);
|
||||
}
|
||||
|
||||
// Jira ticket melding
|
||||
if (!isInitialLiveSync && data.jira?.has_new && data.jira.new_issue) {
|
||||
const j = data.jira.new_issue;
|
||||
sendBrowserNotification(
|
||||
`⚡ Nieuw Jira ticket: [${j.key}]`,
|
||||
`${j.summary} (${j.priority})`,
|
||||
j.url || '/jira.php',
|
||||
`fociz-jira-${j.key}`
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatch custom event voor actieve pagina's
|
||||
if (data.mail?.has_new || (data.mail?.latest_id && data.mail.latest_id !== lastKnownMailId)) {
|
||||
window.dispatchEvent(new CustomEvent('fociz:mail-updated', { detail: data.mail }));
|
||||
}
|
||||
if (data.jira?.has_new || (data.jira?.newest_key && data.jira.newest_key !== lastKnownJiraKey)) {
|
||||
window.dispatchEvent(new CustomEvent('fociz:jira-updated', { detail: data.jira }));
|
||||
}
|
||||
if (data.calendar?.has_changes) {
|
||||
window.dispatchEvent(new CustomEvent('fociz:calendar-updated', { detail: data.calendar }));
|
||||
}
|
||||
|
||||
if (data.mail?.latest_id) {
|
||||
lastKnownMailId = data.mail.latest_id;
|
||||
localStorage.setItem('fociz_last_mail_id', lastKnownMailId);
|
||||
}
|
||||
if (data.jira?.newest_key) {
|
||||
lastKnownJiraKey = data.jira.newest_key;
|
||||
localStorage.setItem('fociz_last_jira_key', lastKnownJiraKey);
|
||||
}
|
||||
if (data.calendar?.hash) {
|
||||
lastCalendarHash = data.calendar.hash;
|
||||
}
|
||||
|
||||
isInitialLiveSync = false;
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
// Stil negeren bij netwerkvertraging
|
||||
} finally {
|
||||
isLiveSyncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Initialisatie
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
refreshEvents();
|
||||
checkLiveSync();
|
||||
|
||||
// Polling interval elke 60 seconden voor agenda evaluatie
|
||||
setInterval(refreshEvents, 60000);
|
||||
// Evaluatie interval elke 15 seconden voor agenda popups
|
||||
setInterval(evaluateEvents, 15000);
|
||||
// Live sync interval elke 30 seconden voor mail, Jira & calendar updates
|
||||
setInterval(checkLiveSync, 30000);
|
||||
|
||||
// Intelligente throttle voor window focus en tabwisseling (max 1x per 20s)
|
||||
let lastFocusCheck = Date.now();
|
||||
const throttledSync = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastFocusCheck > 20000) {
|
||||
lastFocusCheck = now;
|
||||
checkLiveSync();
|
||||
refreshEvents();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', throttledSync);
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
throttledSync();
|
||||
}
|
||||
});
|
||||
|
||||
updateDotStatus();
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* Fociz Session Guard (Release v1.6.1)
|
||||
* Realtime beveiligingsbeheer voor sessie-inactiviteit conform NIST SP 800-63B / ISO 27001
|
||||
* - Waarschuwt de gebruiker 2 minuten voor het verlopen met aftelklok
|
||||
* - Throttled keepalive pings tijdens actieve interactie
|
||||
* - Synchronisatie over meerdere open browsertabbladen via localStorage
|
||||
* - Beschermd tegen oneindige herlaad- en redirect-loops
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Script configuratie uitlezen
|
||||
var scriptTag = document.currentScript || document.querySelector('script[src*="session-guard.js"]');
|
||||
var rawTimeout = scriptTag ? parseInt(scriptTag.getAttribute('data-timeout'), 10) : 1800;
|
||||
var idleTimeout = (!isNaN(rawTimeout) && rawTimeout > 0) ? rawTimeout : 1800;
|
||||
|
||||
// Waarschuwing 120 seconden voor afloop (of de helft indien timeout kort is)
|
||||
var warningSeconds = idleTimeout > 240 ? 120 : Math.max(10, Math.floor(idleTimeout / 2));
|
||||
|
||||
var now = Date.now();
|
||||
var lastActivity = now;
|
||||
var lastPingTime = now;
|
||||
var isWarningShowing = false;
|
||||
var keepaliveInFlight = false;
|
||||
var hasRedirected = false;
|
||||
|
||||
// Synchronisatie met andere tabbladen:
|
||||
// Alleen een timestamp overnemen als deze GELDIG is en NIET al verlopen is!
|
||||
// Aangezien DEZE pagina zojuist succesvol geladen is, is de sessie NU actief.
|
||||
var storedActivity = localStorage.getItem('fociz_session_last_activity');
|
||||
if (storedActivity) {
|
||||
var parsed = parseInt(storedActivity, 10);
|
||||
if (!isNaN(parsed) && parsed > 0 && parsed <= now && (now - parsed) < (idleTimeout * 1000)) {
|
||||
lastActivity = Math.max(now, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct opslaan zodat alle tabbladen weten dat er zojuist activiteit was
|
||||
try {
|
||||
localStorage.setItem('fociz_session_last_activity', lastActivity.toString());
|
||||
localStorage.removeItem('fociz_session_logged_out');
|
||||
} catch (e) {}
|
||||
|
||||
// Veilige eenmalige redirect helper om redirect loops te voorkomen
|
||||
function redirectToExpired() {
|
||||
if (hasRedirected) return;
|
||||
hasRedirected = true;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_logged_out', Date.now().toString());
|
||||
} catch (e) {}
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
|
||||
// Modal DOM elementen
|
||||
var modalEl = null;
|
||||
var countdownEl = null;
|
||||
var keepaliveBtn = null;
|
||||
var logoutBtn = null;
|
||||
|
||||
function initDom() {
|
||||
modalEl = document.getElementById('focizSessionModal');
|
||||
countdownEl = document.getElementById('focizSessionCountdown');
|
||||
keepaliveBtn = document.getElementById('focizSessionKeepaliveBtn');
|
||||
logoutBtn = document.getElementById('focizSessionLogoutBtn');
|
||||
|
||||
if (keepaliveBtn) {
|
||||
keepaliveBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
sendKeepalive(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
performLogout();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Luister naar tabblad synchronisatie
|
||||
window.addEventListener('storage', function (e) {
|
||||
if (e.key === 'fociz_session_last_activity') {
|
||||
var updated = parseInt(e.newValue, 10);
|
||||
if (!isNaN(updated) && updated > lastActivity) {
|
||||
lastActivity = updated;
|
||||
if (isWarningShowing) {
|
||||
hideModal();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'fociz_session_logged_out') {
|
||||
if (!hasRedirected) {
|
||||
var loggedOutTime = parseInt(e.newValue, 10);
|
||||
if (!isNaN(loggedOutTime) && Math.abs(Date.now() - loggedOutTime) < 10000) {
|
||||
hasRedirected = true;
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Formatteer seconden naar mm:ss
|
||||
function formatTime(seconds) {
|
||||
var m = Math.floor(seconds / 60);
|
||||
var s = seconds % 60;
|
||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
// Toon modal
|
||||
function showModal(remainingSeconds) {
|
||||
if (!modalEl) initDom();
|
||||
if (!modalEl) return;
|
||||
|
||||
isWarningShowing = true;
|
||||
modalEl.classList.remove('hidden');
|
||||
modalEl.classList.add('flex');
|
||||
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = formatTime(remainingSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
// Verberg modal
|
||||
function hideModal() {
|
||||
if (!modalEl) return;
|
||||
isWarningShowing = false;
|
||||
modalEl.classList.add('hidden');
|
||||
modalEl.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Verzend keepalive naar de backend
|
||||
function sendKeepalive(manualClick) {
|
||||
if (hasRedirected || keepaliveInFlight) return;
|
||||
keepaliveInFlight = true;
|
||||
|
||||
if (manualClick && keepaliveBtn) {
|
||||
keepaliveBtn.disabled = true;
|
||||
keepaliveBtn.textContent = 'Verlengen...';
|
||||
}
|
||||
|
||||
fetch('/api/session.php?action=keepalive', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.status === 401) {
|
||||
// Reeds verlopen op de server
|
||||
redirectToExpired();
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
keepaliveInFlight = false;
|
||||
if (manualClick && keepaliveBtn) {
|
||||
keepaliveBtn.disabled = false;
|
||||
keepaliveBtn.textContent = 'Blijf Ingelogd';
|
||||
}
|
||||
|
||||
if (data && data.success) {
|
||||
var currentNow = Date.now();
|
||||
lastActivity = currentNow;
|
||||
lastPingTime = currentNow;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_last_activity', currentNow.toString());
|
||||
} catch (e) {}
|
||||
hideModal();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
keepaliveInFlight = false;
|
||||
if (manualClick && keepaliveBtn) {
|
||||
keepaliveBtn.disabled = false;
|
||||
keepaliveBtn.textContent = 'Blijf Ingelogd';
|
||||
}
|
||||
console.error('Session keepalive error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Direct uitloggen
|
||||
function performLogout() {
|
||||
if (hasRedirected) return;
|
||||
hasRedirected = true;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_logged_out', Date.now().toString());
|
||||
} catch (e) {}
|
||||
fetch('/api/session.php?action=logout', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).finally(function () {
|
||||
window.location.href = '/auth/logout.php';
|
||||
});
|
||||
}
|
||||
|
||||
// Registreer gebruikersactiviteit (throttled)
|
||||
function registerUserActivity() {
|
||||
if (hasRedirected) return;
|
||||
var currentNow = Date.now();
|
||||
|
||||
// Controleer of de sessie al verlopen is (bijv. laptop uit slaapstand)
|
||||
var elapsedSec = Math.floor((currentNow - lastActivity) / 1000);
|
||||
if (elapsedSec >= idleTimeout) {
|
||||
redirectToExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
// Als de waarschuwingsmodal actief is, vereisen we expliciete klik op 'Blijf Ingelogd'
|
||||
if (isWarningShowing) return;
|
||||
|
||||
// Lokale activiteit bijwerken (maximaal eens per 3 seconden wegschrijven)
|
||||
if (currentNow - lastActivity > 3000) {
|
||||
lastActivity = currentNow;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_last_activity', currentNow.toString());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Server keepalive ping (maximaal eens per 60 seconden)
|
||||
var minPingInterval = Math.min(60000, Math.max(15000, (idleTimeout * 1000) / 4));
|
||||
if (currentNow - lastPingTime > minPingInterval) {
|
||||
lastPingTime = currentNow;
|
||||
sendKeepalive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct controleren bij terugkeer naar tabblad of ontwaken uit slaapstand
|
||||
function checkIdleOnWakeup() {
|
||||
if (hasRedirected) return;
|
||||
var currentNow = Date.now();
|
||||
var stored = localStorage.getItem('fociz_session_last_activity');
|
||||
if (stored) {
|
||||
var parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed > 0 && parsed <= currentNow) {
|
||||
lastActivity = Math.max(lastActivity, parsed);
|
||||
}
|
||||
}
|
||||
var elapsedSec = Math.floor((currentNow - lastActivity) / 1000);
|
||||
if (elapsedSec >= idleTimeout) {
|
||||
redirectToExpired();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', checkIdleOnWakeup);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkIdleOnWakeup();
|
||||
}
|
||||
});
|
||||
|
||||
// Event listeners voor menselijke interactie
|
||||
var events = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart'];
|
||||
events.forEach(function (evt) {
|
||||
window.addEventListener(evt, registerUserActivity, { passive: true });
|
||||
});
|
||||
|
||||
// Hoofdtimer interval (elke seconde)
|
||||
function tick() {
|
||||
if (hasRedirected) return;
|
||||
var currentNow = Date.now();
|
||||
var elapsedSec = Math.floor((currentNow - lastActivity) / 1000);
|
||||
var remainingSec = idleTimeout - elapsedSec;
|
||||
|
||||
if (remainingSec <= 0) {
|
||||
redirectToExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingSec <= warningSeconds) {
|
||||
// Binnen waarschuwingsvenster
|
||||
showModal(remainingSec);
|
||||
} else if (isWarningShowing) {
|
||||
// Waarschuwing niet meer van toepassing (bijv. verlengd vanuit ander tabblad)
|
||||
hideModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Starten zodra DOM gereed is
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initDom();
|
||||
setInterval(tick, 1000);
|
||||
});
|
||||
} else {
|
||||
initDom();
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
})();
|
||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB |
@@ -7,83 +7,45 @@
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
|
||||
start_secure_session();
|
||||
|
||||
// 1. CSRF State validatie & Sessieherstel
|
||||
$receivedState = $_GET['state'] ?? '';
|
||||
$stateUserId = !empty($receivedState) ? verify_oauth_state($receivedState) : null;
|
||||
|
||||
// Als de sessie verloren is gegaan tijdens de externe cross-site redirect vanaf Microsoft,
|
||||
// herstellen we de gebruiker veilig aan de hand van de cryptografisch ondertekende OAuth state!
|
||||
if (empty($_SESSION['user_id']) && $stateUserId) {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmtUser = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?");
|
||||
$stmtUser->execute([$stateUserId]);
|
||||
$recoveredUser = $stmtUser->fetch();
|
||||
if ($recoveredUser) {
|
||||
$_SESSION['user_id'] = $recoveredUser['id'];
|
||||
$_SESSION['user_name'] = $recoveredUser['name'];
|
||||
$_SESSION['user_email'] = $recoveredUser['email'];
|
||||
$_SESSION['user_role'] = $recoveredUser['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
clear_remember_cookie();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Foutafhandeling
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Controleer authenticatie
|
||||
$user = get_logged_in_user();
|
||||
if (!$user) {
|
||||
header('Location: /auth/login.php?error=' . urlencode('Sessie verlopen tijdens Microsoft koppeling. Log opnieuw in en probeer het nogmaals.'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = require_auth();
|
||||
$db = get_db_connection();
|
||||
|
||||
// 3. Foutafhandeling vanuit Microsoft (bijv. gebruiker weigerde machtiging)
|
||||
// 1. Foutafhandeling vanuit Microsoft
|
||||
if (!empty($_GET['error'])) {
|
||||
$errorDescription = $_GET['error_description'] ?? $_GET['error'];
|
||||
header('Location: /profile.php?error=' . urlencode("Microsoft melding: {$errorDescription}"));
|
||||
header('Location: /integrations.php?error=' . urlencode("Microsoft melding: {$errorDescription}"));
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3b. Afhandeling van beheerderstoestemming (Admin Consent)
|
||||
// Microsoft stuurt bij het /adminconsent endpoint geen state mee, enkel ?admin_consent=True&tenant=...
|
||||
if (!empty($_GET['admin_consent'])) {
|
||||
header('Location: /admin.php?success=' . urlencode('Beheerdersgoedkeuring voor uw organisatie is succesvol verleend! U en uw collega\'s kunnen nu direct hun Outlook agenda en e-mail koppelen via Fociz.'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. Valideer de state (via HMAC handtekening of fallback sessiestate)
|
||||
// 2. CSRF State validatie
|
||||
$receivedState = $_GET['state'] ?? '';
|
||||
$expectedState = $_SESSION['ms_oauth_state'] ?? '';
|
||||
unset($_SESSION['ms_oauth_state'], $_SESSION['ms_oauth_user_id']);
|
||||
unset($_SESSION['ms_oauth_state']);
|
||||
|
||||
if (!$stateUserId && (empty($receivedState) || empty($expectedState) || !hash_equals($expectedState, $receivedState))) {
|
||||
header('Location: /profile.php?error=' . urlencode('Ongeldige sessie of CSRF-controle mislukt. Probeer opnieuw in te loggen via Microsoft.'));
|
||||
if (empty($receivedState) || !hash_equals($expectedState, $receivedState)) {
|
||||
header('Location: /integrations.php?error=' . urlencode('Ongeldige sessie of CSRF-controle mislukt. Probeer opnieuw in te loggen via Microsoft.'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Autorisatiecode controleren
|
||||
// 3. Autorisatiecode controleren
|
||||
$code = $_GET['code'] ?? '';
|
||||
if (empty($code)) {
|
||||
header('Location: /profile.php?error=' . urlencode('Geen autorisatiecode ontvangen van Microsoft.'));
|
||||
header('Location: /integrations.php?error=' . urlencode('Geen autorisatiecode ontvangen van Microsoft.'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. Centrale instellingen ophalen
|
||||
$appConfig = microsoft_get_app_config();
|
||||
// 4. Instellingen ophalen
|
||||
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||
$stmt->execute([$user['id']]);
|
||||
$row = $stmt->fetch();
|
||||
$savedSettings = json_decode($row['settings'] ?? '{}', true) ?: [];
|
||||
|
||||
$clientId = $appConfig['client_id'];
|
||||
$clientSecret = $appConfig['client_secret'];
|
||||
$tenantId = (!empty($appConfig['tenant_id']) && $appConfig['tenant_id'] !== 'common') ? $appConfig['tenant_id'] : 'planningsagenda.nl';
|
||||
$clientId = !empty($savedSettings['client_id']) ? $savedSettings['client_id'] : fociz_env('MICROSOFT_CLIENT_ID', '');
|
||||
$clientSecret = !empty($savedSettings['client_secret']) ? $savedSettings['client_secret'] : fociz_env('MICROSOFT_CLIENT_SECRET', '');
|
||||
$tenantId = !empty($savedSettings['tenant_id']) ? $savedSettings['tenant_id'] : fociz_env('MICROSOFT_TENANT_ID', 'common');
|
||||
|
||||
if (empty($clientId) || empty($clientSecret)) {
|
||||
header('Location: /profile.php?error=' . urlencode('Microsoft 365 configuratie (Client ID of Secret) ontbreekt in Fociz. Neem contact op met de beheerder.'));
|
||||
header('Location: /integrations.php?error=' . urlencode('Client ID of Client Secret ontbreekt in Fociz. Vul deze eerst in bij de Outlook instellingen.'));
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -93,7 +55,7 @@ $redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
|
||||
$tokenResult = microsoft_exchange_code($code, $clientId, $clientSecret, $redirectUri, $tenantId);
|
||||
|
||||
if (!$tokenResult['success']) {
|
||||
header('Location: /profile.php?error=' . urlencode('Token uitwisseling mislukt: ' . $tokenResult['error']));
|
||||
header('Location: /integrations.php?error=' . urlencode('Token uitwisseling mislukt: ' . $tokenResult['error']));
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -108,24 +70,15 @@ $accountEmail = $profile['mail'] ?? ($profile['userPrincipalName'] ?? $user['ema
|
||||
$accountName = $profile['displayName'] ?? 'Microsoft 365 Account';
|
||||
|
||||
// 7. Opslaan in MariaDB
|
||||
$stmtExisting = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||
$stmtExisting->execute([$user['id']]);
|
||||
$existingRow = $stmtExisting->fetch();
|
||||
$existingSettings = json_decode($existingRow['settings'] ?? '{}', true) ?: [];
|
||||
|
||||
$newSettings = array_merge($existingSettings, [
|
||||
$newSettings = array_merge($savedSettings, [
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'tenant_id' => $tenantId,
|
||||
'expires_at' => $expiresAt,
|
||||
'account_email' => $accountEmail,
|
||||
'account_name' => $accountName,
|
||||
]);
|
||||
|
||||
// Als de ingelogde gebruiker Beheerder (ADMIN) is, bewaren we ook de centrale app credentials
|
||||
if ($user['role'] === 'ADMIN') {
|
||||
$newSettings['client_id'] = $clientId;
|
||||
$newSettings['client_secret'] = $clientSecret;
|
||||
$newSettings['tenant_id'] = $tenantId;
|
||||
}
|
||||
|
||||
$saveStmt = $db->prepare("
|
||||
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, access_token, refresh_token, settings, last_sync_at)
|
||||
VALUES (?, 'OUTLOOK', 1, 0, ?, ?, ?, NOW())
|
||||
@@ -137,21 +90,9 @@ $saveStmt = $db->prepare("
|
||||
settings = VALUES(settings),
|
||||
last_sync_at = NOW()
|
||||
");
|
||||
$saveStmt->execute([
|
||||
$user['id'],
|
||||
fociz_encrypt($accessToken),
|
||||
fociz_encrypt($refreshToken),
|
||||
json_encode($newSettings)
|
||||
]);
|
||||
$saveStmt->execute([$user['id'], $accessToken, $refreshToken, json_encode($newSettings)]);
|
||||
|
||||
// 8. Succesvol terugkeren naar de gewenste pagina of het Persoonlijk Profiel
|
||||
$returnTo = $_SESSION['ms_oauth_return_to'] ?? '/profile.php';
|
||||
unset($_SESSION['ms_oauth_return_to']);
|
||||
|
||||
$redirectUrl = ($returnTo === '/mail.php')
|
||||
? '/mail.php?success=' . urlencode("Microsoft Outlook 365 e-mail & agenda succesvol gekoppeld als {$accountEmail}!")
|
||||
: '/profile.php?success=' . urlencode("Microsoft Outlook succesvol gekoppeld als {$accountEmail}!");
|
||||
|
||||
header('Location: ' . $redirectUrl);
|
||||
// 8. Succesvol terugkeren naar de Integraties Hub
|
||||
header('Location: /integrations.php?success=' . urlencode("Microsoft Outlook succesvol gekoppeld als {$accountEmail}!"));
|
||||
exit;
|
||||
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Wachtwoord Vergeten Aanvraag
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/mailer.php';
|
||||
|
||||
start_secure_session();
|
||||
|
||||
// Indien al ingelogd, direct doorsturen naar de planner
|
||||
if (get_logged_in_user()) {
|
||||
header('Location: /planner.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = null;
|
||||
$submitted = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$clientIp = fociz_get_client_ip();
|
||||
$ipRateKey = "forgot_ip_{$clientIp}";
|
||||
$ipLimit = fociz_check_rate_limit($ipRateKey, 5, 900);
|
||||
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$emailRateKey = "forgot_email_{$email}";
|
||||
$emailLimit = fociz_check_rate_limit($emailRateKey, 3, 900);
|
||||
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$error = "Sessie verlopen of beveiligingstoken ongeldig. Ververs de pagina en probeer opnieuw.";
|
||||
} elseif (!$ipLimit['allowed'] || !$emailLimit['allowed']) {
|
||||
$retryAfter = max($ipLimit['retry_after'], $emailLimit['retry_after']);
|
||||
$error = "Te veel herstelaanvragen. Probeer het over " . ceil($retryAfter / 60) . " minuten opnieuw.";
|
||||
} elseif (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$error = "Vul een geldig e-mailadres in.";
|
||||
} else {
|
||||
fociz_hit_rate_limit($ipRateKey, 900);
|
||||
fociz_hit_rate_limit($emailRateKey, 900);
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
|
||||
// Zoek gebruiker op
|
||||
$stmt = $db->prepare("SELECT id, name, email FROM users WHERE email = ?");
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user) {
|
||||
// Verwijder eventuele eerdere openstaande reset tokens voor dit e-mailadres
|
||||
$stmtClean = $db->prepare("DELETE FROM password_resets WHERE email = ? AND used_at IS NULL");
|
||||
$stmtClean->execute([$email]);
|
||||
|
||||
// Genereer veilig uniek token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour'));
|
||||
|
||||
$stmtInsert = $db->prepare("
|
||||
INSERT INTO password_resets (email, token, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
");
|
||||
$stmtInsert->execute([$email, $token, $expiresAt]);
|
||||
|
||||
// Bouw herstel-URL
|
||||
$baseUrl = rtrim(APP_URL, '/');
|
||||
$resetUrl = "{$baseUrl}/auth/reset-password.php?token={$token}";
|
||||
|
||||
// Verstuur herstelmail via de nieuwe FocizMailer
|
||||
$mailResult = FocizMailer::sendPasswordResetEmail($user['email'], $user['name'], $resetUrl);
|
||||
if (!$mailResult['success']) {
|
||||
$errMsg = $mailResult['error'] ?? 'Mailserver fout';
|
||||
error_log("Fout bij verzenden wachtwoordherstel mail naar {$email}: {$errMsg}");
|
||||
$error = "De herstelmail kon niet worden verzonden via de mailserver: " . htmlspecialchars($errMsg);
|
||||
} else {
|
||||
$submitted = true;
|
||||
}
|
||||
} else {
|
||||
// In development omgeving geven we directe diagnostische feedback
|
||||
if (defined('APP_ENV') && APP_ENV === 'development') {
|
||||
$error = "Er is geen Fociz account gevonden met het e-mailadres '{$email}'. Controleer of dit e-mailadres correct is ingevoerd.";
|
||||
} else {
|
||||
// Anti-enumeratie op productie: toon altijd succes
|
||||
$submitted = true;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log("Wachtwoord vergeten fout: " . $e->getMessage());
|
||||
$error = "Er is een technische fout opgetreden. Probeer het over enkele ogenblikken opnieuw.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = "Fociz — Wachtwoord Vergeten";
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<script>
|
||||
(function() {
|
||||
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
|
||||
var isDark = true;
|
||||
if (activeTheme === 'dark') isDark = true;
|
||||
else if (activeTheme === 'light') isDark = false;
|
||||
else if (activeTheme === 'system') {
|
||||
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
})();
|
||||
</script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: {
|
||||
850: '#151f32',
|
||||
950: '#070b12',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
|
||||
|
||||
<div class="w-full max-w-md bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
|
||||
<i data-lucide="key-round" class="w-7 h-7"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-black tracking-tight text-slate-900 dark:text-white">Wachtwoord Vergeten?</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1">
|
||||
Geen probleem. Vul uw e-mailadres in en we sturen u een veilige herstellink.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="mb-6 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
|
||||
<span><?= htmlspecialchars($error) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($submitted): ?>
|
||||
<div class="space-y-6">
|
||||
<div class="p-5 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-800 dark:text-emerald-300 text-sm">
|
||||
<div class="flex items-start space-x-3">
|
||||
<i data-lucide="mail-check" class="w-6 h-6 flex-shrink-0 text-emerald-600 dark:text-emerald-400 mt-0.5"></i>
|
||||
<div>
|
||||
<strong class="font-bold block mb-1">Herstellink verzonden!</strong>
|
||||
<span>
|
||||
Als het opgegeven e-mailadres bij ons bekend is, ontvangt u binnen enkele ogenblikken een e-mail met instructies om uw wachtwoord opnieuw in te stellen.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-slate-500 dark:text-slate-400 space-y-2 bg-slate-50 dark:bg-slate-900/50 p-4 rounded-xl border border-slate-200 dark:border-slate-800">
|
||||
<p>💡 <strong>Tip:</strong> Heeft u na enkele minuten nog niets ontvangen?</p>
|
||||
<ul class="list-disc list-inside space-y-1 pl-1">
|
||||
<li>Controleer uw map met <em>Ongewenste e-mail</em> of <em>Spam</em>.</li>
|
||||
<li>Controleer of u het juiste e-mailadres heeft ingevuld.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="/auth/login.php" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
<span>Terug naar Inloggen</span>
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="POST" action="/auth/forgot-password.php" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Uw e-mailadres</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autofocus
|
||||
placeholder="uw.naam@bedrijf.nl"
|
||||
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>"
|
||||
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||
<i data-lucide="send" class="w-4 h-4"></i>
|
||||
<span>Herstellink Verzenden</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/auth/login.php" class="inline-flex items-center space-x-1.5 text-xs text-indigo-600 dark:text-indigo-400 hover:underline font-medium">
|
||||
<i data-lucide="arrow-left" class="w-3.5 h-3.5"></i>
|
||||
<span>Terug naar Inloggen</span>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center text-xs text-slate-500">
|
||||
Beveiligd met end-to-end authenticatie • Fociz 2026
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,11 +14,11 @@ if (!$invite) {
|
||||
<!DOCTYPE html>
|
||||
<html lang='nl' class='dark'>
|
||||
<head><script src='https://cdn.tailwindcss.com'></script></head>
|
||||
<body class='bg-[#0c0f17] text-white min-h-screen flex items-center justify-center p-4'>
|
||||
<div class='max-w-md bg-[#111622] border border-slate-800 rounded-2xl p-8 text-center shadow-xs'>
|
||||
<h2 class='text-xl font-bold text-rose-400 mb-2'>Ongeldige Uitnodiging</h2>
|
||||
<body class='bg-[#090d16] text-white min-h-screen flex items-center justify-center p-4'>
|
||||
<div class='max-w-md bg-slate-900 border border-slate-800 rounded-2xl p-8 text-center'>
|
||||
<h2 class='text-xl font-bold text-red-400 mb-2'>Ongeldige Uitnodiging</h2>
|
||||
<p class='text-sm text-slate-400 mb-6'>Deze uitnodigingslink is ongeldig, verlopen of al eerder gebruikt.</p>
|
||||
<a href='/auth/login.php' class='px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl text-xs transition shadow-xs'>Naar Inloggen</a>
|
||||
<a href='/auth/login.php' class='px-4 py-2 bg-emerald-500 text-slate-950 font-bold rounded-xl text-xs'>Naar Inloggen</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -39,8 +39,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
if (empty($name) || empty($password) || empty($totpCode)) {
|
||||
$error = "Alle velden (inclusief 2FA verificatiecode) zijn verplicht.";
|
||||
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
|
||||
$error = $pwErr;
|
||||
} elseif (strlen($password) < 8) {
|
||||
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn.";
|
||||
} elseif ($password !== $confirmPassword) {
|
||||
$error = "Wachtwoorden komen niet overeen.";
|
||||
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
|
||||
@@ -75,29 +75,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$db->commit();
|
||||
|
||||
fociz_log_audit($newUserId, 'USER_REGISTERED_VIA_INVITE', 'user', $newUserId, ['email' => $invite['email'], 'role' => $invite['role']], 'SUCCESS', $invite['email']);
|
||||
|
||||
// Start dagsessie met verse sessie ID (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6)
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
// Start sessie
|
||||
$_SESSION['user_id'] = $newUserId;
|
||||
$_SESSION['user_name'] = $name;
|
||||
$_SESSION['user_email'] = $invite['email'];
|
||||
$_SESSION['user_role'] = $invite['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
clear_remember_cookie();
|
||||
issue_day_session_token($newUserId);
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session($newUserId, $newSessionId, $maxIdleSeconds);
|
||||
unset($_SESSION['invite_totp_secret']);
|
||||
|
||||
header('Location: /planner.php');
|
||||
@@ -119,58 +102,27 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage -->
|
||||
<script>
|
||||
(function() {
|
||||
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
|
||||
var isDark = true;
|
||||
if (activeTheme === 'dark') isDark = true;
|
||||
else if (activeTheme === 'light') isDark = false;
|
||||
else if (activeTheme === 'system') {
|
||||
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
})();
|
||||
</script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: { 850: '#151f32', 950: '#070b12' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>body { font-family: 'Plus Jakarta Sans', sans-serif; }</style>
|
||||
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
|
||||
|
||||
<div class="w-full max-w-lg bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
|
||||
<div class="w-full max-w-lg bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
|
||||
<i data-lucide="shield-check" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-black text-slate-900 dark:text-white">Welkom bij Fociz</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
Uitgenodigd door <strong class="text-slate-900 dark:text-slate-200"><?= htmlspecialchars($invite['inviter_name']) ?></strong> voor <span class="text-indigo-600 dark:text-indigo-400 font-mono text-xs bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 px-2 py-0.5 rounded-lg"><?= htmlspecialchars($invite['email']) ?></span>
|
||||
<h1 class="text-2xl font-bold text-white">Welkom bij Fociz</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Uitgenodigd door <strong class="text-slate-200"><?= htmlspecialchars($invite['inviter_name']) ?></strong> voor <span class="text-emerald-400 font-mono text-xs bg-emerald-500/10 px-2 py-0.5 rounded"><?= htmlspecialchars($invite['email']) ?></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="mb-6 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
|
||||
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
|
||||
<span><?= htmlspecialchars($error) ?></span>
|
||||
</div>
|
||||
@@ -180,67 +132,55 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Uw naam</label>
|
||||
<input type="text" name="name" required placeholder="Bijv. Sarah de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Uw naam</label>
|
||||
<input type="text" name="name" required placeholder="Bijv. Sarah de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 12 tekens)</label>
|
||||
<input type="password" name="password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Verplichting -->
|
||||
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
|
||||
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider text-emerald-400">
|
||||
Verplichte Tweestapsverificatie (2FA)
|
||||
</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
<p class="text-xs text-slate-400">
|
||||
Scan deze QR-code in uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
|
||||
</p>
|
||||
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
|
||||
<div id="qrcode" class="w-40 h-40 mx-auto flex items-center justify-center"></div>
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xl">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-40 h-40 mx-auto">
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono">
|
||||
Sleutel: <strong class="text-indigo-600 dark:text-indigo-400"><?= htmlspecialchars($secret) ?></strong>
|
||||
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono">
|
||||
Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1.5">
|
||||
Voer de 6-cijferige code uit uw app in:
|
||||
</label>
|
||||
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-2 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
|
||||
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-6 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<span>Account Activeren & Inloggen</span>
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/qrcode.min.js"></script>
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
const qrContainer = document.getElementById('qrcode');
|
||||
if (qrContainer) {
|
||||
new QRCode(qrContainer, {
|
||||
text: <?= json_encode($otpAuthUrl) ?>,
|
||||
width: 160,
|
||||
height: 160,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,98 +12,32 @@ if (!has_admin_user()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Als de sessie verlopen, ingetrokken of gewijzigd is: beëindig deze direct en wis cookies
|
||||
if (isset($_GET['expired']) || isset($_GET['revoked']) || isset($_GET['ip_changed'])) {
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
fociz_terminate_session(session_id());
|
||||
}
|
||||
clear_day_session_cookie();
|
||||
$_SESSION = [];
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Als al ingelogd (via actieve sessie of persistente cookie), ga direct naar planner
|
||||
$currentUser = get_logged_in_user();
|
||||
if ($currentUser) {
|
||||
// Als al volledig ingelogd en 2FA geverifieerd, ga naar planner
|
||||
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
|
||||
header('Location: /planner.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = null;
|
||||
$infoMessage = null;
|
||||
if (isset($_GET['expired'])) {
|
||||
$infoMessage = "Uw sessie is beeïndigd";
|
||||
}
|
||||
if (isset($_GET['revoked'])) {
|
||||
$infoMessage = "Uw sessie is op afstand beëindigd of ingetrokken. Log opnieuw in om verder te gaan.";
|
||||
}
|
||||
if (isset($_GET['ip_changed'])) {
|
||||
$infoMessage = "U bent ingelogd vanaf een andere netwerklocatie. Uit veiligheidsoverwegingen dient u opnieuw in te loggen met 2FA.";
|
||||
}
|
||||
if (isset($_GET['ip_verified'])) {
|
||||
$infoMessage = "Nieuwe inloglocatie succesvol geverifieerd! U kunt nu veilig inloggen.";
|
||||
}
|
||||
$step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
|
||||
|
||||
// Terug naar stap 1
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
|
||||
unset(
|
||||
$_SESSION['pending_2fa_user_id'],
|
||||
$_SESSION['pending_2fa_user_email'],
|
||||
$_SESSION['pending_2fa_user_name'],
|
||||
$_SESSION['pending_new_ip'],
|
||||
$_SESSION['pending_new_ip_mail_sent']
|
||||
);
|
||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email']);
|
||||
header('Location: /auth/login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verificatiemail opnieuw verzenden bij nieuw IP
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'resend_ip_mail' && !empty($_SESSION['pending_2fa_user_id']) && !empty($_SESSION['pending_new_ip'])) {
|
||||
$resendUserId = (int)$_SESSION['pending_2fa_user_id'];
|
||||
$resendRateKey = "resend_ip_mail_{$resendUserId}";
|
||||
$resendLimit = fociz_check_rate_limit($resendRateKey, 2, 60);
|
||||
|
||||
if (!$resendLimit['allowed']) {
|
||||
$error = "Wacht even voordat u opnieuw een e-mail aanvraagt (over " . ceil($resendLimit['retry_after']) . " seconden).";
|
||||
} else {
|
||||
fociz_hit_rate_limit($resendRateKey, 60);
|
||||
$resendEmail = $_SESSION['pending_2fa_user_email'] ?? '';
|
||||
$resendName = $_SESSION['pending_2fa_user_name'] ?? '';
|
||||
$resendIp = $_SESSION['pending_new_ip'];
|
||||
$sent = fociz_send_new_ip_verification($resendUserId, $resendEmail, $resendName, $resendIp);
|
||||
if ($sent) {
|
||||
$infoMessage = "Er is een nieuwe verificatielink verstuurd naar " . htmlspecialchars($resendEmail) . ".";
|
||||
$_SESSION['pending_new_ip_mail_sent'] = true;
|
||||
} else {
|
||||
$error = "De mailserver kon de e-mail niet direct afleveren. U kunt echter direct hieronder inloggen met uw 2FA Authenticator code om deze locatie te autoriseren.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST Verwerking
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$db = get_db_connection();
|
||||
$clientIp = fociz_get_client_ip();
|
||||
$ipRateKey = "login_ip_{$clientIp}";
|
||||
$ipLimit = fociz_check_rate_limit($ipRateKey, 10, 900);
|
||||
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$error = "Sessie verlopen of beveiligingstoken ongeldig. Ververs de pagina en probeer opnieuw.";
|
||||
} elseif (!$ipLimit['allowed']) {
|
||||
$error = "Te veel inlogpogingen vanaf dit netwerkadres. Probeer het over " . ceil($ipLimit['retry_after'] / 60) . " minuten opnieuw.";
|
||||
} elseif ($step === 1) {
|
||||
// STAP 1: Wachtwoord verificatie
|
||||
if ($step === 1) {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$password = $_POST['password'] ?? '';
|
||||
$emailRateKey = "login_email_{$email}";
|
||||
$emailLimit = fociz_check_rate_limit($emailRateKey, 5, 900);
|
||||
|
||||
if (!$emailLimit['allowed']) {
|
||||
$error = "Dit account is tijdelijk geblokkeerd wegens te veel mislukte pogingen. Probeer het over " . ceil($emailLimit['retry_after'] / 60) . " minuten opnieuw.";
|
||||
} elseif (empty($email) || empty($password)) {
|
||||
if (empty($email) || empty($password)) {
|
||||
$error = "Vul e-mailadres en wachtwoord in.";
|
||||
} else {
|
||||
$stmt = $db->prepare("SELECT id, name, email, password_hash, two_factor_secret, two_factor_enabled FROM users WHERE email = ?");
|
||||
@@ -111,37 +45,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user && password_verify($password, $user['password_hash'])) {
|
||||
fociz_clear_rate_limit($emailRateKey);
|
||||
// Controleer of de inloglocatie/IP bekend is
|
||||
$isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
|
||||
|
||||
if (!$isIpKnown) {
|
||||
// Stuur verificatie-mail en log audit challenge
|
||||
$mailSent = fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
|
||||
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp, 'mail_sent' => $mailSent], 'PENDING', $user['email']);
|
||||
|
||||
// Laat legitieme gebruiker doorgaan naar Stap 2 (2FA TOTP).
|
||||
// Conform NIST SP 800-63B autoriseert de fysieke Authenticator code de nieuwe locatie.
|
||||
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
|
||||
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
||||
$_SESSION['pending_new_ip'] = $clientIp;
|
||||
$_SESSION['pending_new_ip_mail_sent'] = $mailSent;
|
||||
$step = 2;
|
||||
} else {
|
||||
// Bekende locatie: direct door naar 2FA
|
||||
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
||||
unset($_SESSION['pending_new_ip'], $_SESSION['pending_new_ip_mail_sent']);
|
||||
$step = 2;
|
||||
}
|
||||
} else {
|
||||
fociz_hit_rate_limit($ipRateKey, 900);
|
||||
if (!empty($email)) {
|
||||
fociz_hit_rate_limit($emailRateKey, 900);
|
||||
}
|
||||
fociz_log_audit($user ? (int)$user['id'] : null, 'LOGIN_FAILED', 'user', $user ? (int)$user['id'] : null, ['reason' => 'Invalid credentials', 'ip' => $clientIp], 'FAILURE', $email);
|
||||
$error = "Onjuist e-mailadres of wachtwoord.";
|
||||
}
|
||||
}
|
||||
@@ -149,38 +58,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// STAP 2: 2FA TOTP Code verificatie
|
||||
elseif ($step === 2) {
|
||||
$userId = $_SESSION['pending_2fa_user_id'] ?? null;
|
||||
$totpCode = trim($_POST['totp_code'] ?? '');
|
||||
$totpCode = trim(str_replace(' ', '', $_POST['totp_code'] ?? ''));
|
||||
|
||||
if (!$userId) {
|
||||
header('Location: /auth/login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$totpRateKey = "login_totp_{$userId}";
|
||||
$totpLimit = fociz_check_rate_limit($totpRateKey, 5, 900);
|
||||
|
||||
if (!$totpLimit['allowed']) {
|
||||
$error = "Te veel ongeldige verificatiecodes. Probeer het over " . ceil($totpLimit['retry_after'] / 60) . " minuten opnieuw.";
|
||||
} elseif (empty($totpCode)) {
|
||||
$error = "Vul de 6-cijferige 2FA code in uit uw authenticator app.";
|
||||
if (!$userId || empty($totpCode)) {
|
||||
$error = "Vul de 6-cijferige 2FA verificatiecode in.";
|
||||
} else {
|
||||
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
|
||||
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_secret, backup_codes FROM users WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
$isValid = false;
|
||||
|
||||
// Controleer reguliere TOTP code
|
||||
if (!empty($user['two_factor_secret'])) {
|
||||
try {
|
||||
$isValid = TOTP::verifyCode($user['two_factor_secret'], $totpCode);
|
||||
} catch (Throwable $t) {
|
||||
error_log("TOTP verificatiefout: " . $t->getMessage());
|
||||
$isValid = false;
|
||||
}
|
||||
// 1. Controleer TOTP code
|
||||
if (!empty($user['two_factor_secret']) && TOTP::verifyCode($user['two_factor_secret'], $totpCode)) {
|
||||
$isValid = true;
|
||||
}
|
||||
|
||||
// Fallback: controleer 8-karakter backup codes
|
||||
// 2. Controleer optioneel backup codes
|
||||
if (!$isValid && !empty($user['backup_codes'])) {
|
||||
$backupCodes = json_decode($user['backup_codes'], true) ?: [];
|
||||
$codeUpper = strtoupper($totpCode);
|
||||
@@ -195,60 +89,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
if ($isValid) {
|
||||
fociz_clear_rate_limit($totpRateKey);
|
||||
fociz_clear_rate_limit($ipRateKey);
|
||||
|
||||
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
|
||||
// Autoriseert het IP-adres definitief in user_known_ips
|
||||
fociz_register_user_ip((int)$user['id'], $clientIp, true);
|
||||
|
||||
$wasNewIp = !empty($_SESSION['pending_new_ip']);
|
||||
if ($wasNewIp) {
|
||||
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_VERIFIED_VIA_2FA', 'user_known_ips', (int)$user['id'], ['ip' => $clientIp], 'SUCCESS', $user['email']);
|
||||
}
|
||||
|
||||
// Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6):
|
||||
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
// Volledige login succesvol
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['user_name'] = $user['name'];
|
||||
$_SESSION['user_email'] = $user['email'];
|
||||
$_SESSION['user_role'] = $user['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
|
||||
// Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag)
|
||||
issue_day_session_token((int)$user['id']);
|
||||
|
||||
// Registreer actieve sessie in user_sessions (SOC 2 / ISO 27001)
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
|
||||
|
||||
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA', 'was_new_ip' => $wasNewIp], 'SUCCESS', $user['email']);
|
||||
|
||||
unset(
|
||||
$_SESSION['pending_2fa_user_id'],
|
||||
$_SESSION['pending_2fa_user_email'],
|
||||
$_SESSION['pending_2fa_user_name'],
|
||||
$_SESSION['pending_new_ip'],
|
||||
$_SESSION['pending_new_ip_mail_sent']
|
||||
);
|
||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
|
||||
|
||||
header('Location: /planner.php');
|
||||
exit;
|
||||
} else {
|
||||
fociz_hit_rate_limit($totpRateKey, 900);
|
||||
fociz_hit_rate_limit($ipRateKey, 900);
|
||||
fociz_log_audit((int)$userId, 'LOGIN_MFA_FAILED', 'user', (int)$userId, ['ip' => $clientIp], 'FAILURE', $_SESSION['pending_2fa_user_email'] ?? null);
|
||||
$error = "Onjuiste 2FA code. Controleer uw authenticator app en probeer opnieuw.";
|
||||
}
|
||||
}
|
||||
@@ -263,71 +115,28 @@ $pageTitle = "Fociz — Inloggen";
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage vóór DOM render -->
|
||||
<script>
|
||||
(function() {
|
||||
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
|
||||
var isDark = true;
|
||||
if (activeTheme === 'dark') isDark = true;
|
||||
else if (activeTheme === 'light') isDark = false;
|
||||
else if (activeTheme === 'system') {
|
||||
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
})();
|
||||
</script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: {
|
||||
850: '#151f32',
|
||||
950: '#070b12',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
</style>
|
||||
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
|
||||
|
||||
<div class="w-full max-w-md bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
|
||||
<div class="w-full max-w-md bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
|
||||
<i data-lucide="shield-check" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Fociz</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-white">Fociz</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Veilig inloggen met tweestapsverificatie (2FA)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($infoMessage): ?>
|
||||
<div class="mb-6 p-4 rounded-2xl bg-sky-500/10 border border-sky-500/30 flex items-start space-x-3 text-sky-800 dark:text-sky-300 text-xs sm:text-sm">
|
||||
<i data-lucide="info" class="w-5 h-5 flex-shrink-0 mt-0.5 text-sky-600 dark:text-sky-400"></i>
|
||||
<span><?= htmlspecialchars($infoMessage) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="mb-6 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
|
||||
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
|
||||
<span><?= htmlspecialchars($error) ?></span>
|
||||
</div>
|
||||
@@ -336,25 +145,21 @@ $pageTitle = "Fociz — Inloggen";
|
||||
<?php if ($step === 1): ?>
|
||||
<!-- STAP 1: E-mail & Wachtwoord -->
|
||||
<form method="POST" action="/auth/login.php" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres</label>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">E-mailadres</label>
|
||||
<div class="relative">
|
||||
<input type="email" name="email" required autofocus placeholder="uw.naam@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<input type="email" name="email" required autofocus placeholder="uw.naam@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Wachtwoord</label>
|
||||
<a href="/auth/forgot-password.php" class="text-xs text-indigo-600 dark:text-indigo-400 hover:underline font-medium">Wachtwoord vergeten?</a>
|
||||
</div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord</label>
|
||||
<div class="relative">
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<button type="submit" class="w-full mt-4 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<span>Verder naar 2FA</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</button>
|
||||
@@ -364,51 +169,30 @@ $pageTitle = "Fociz — Inloggen";
|
||||
|
||||
<!-- STAP 2: 2FA TOTP Code -->
|
||||
<form method="POST" action="/auth/login.php" class="space-y-5">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<?php if (!empty($_SESSION['pending_new_ip'])): ?>
|
||||
<div class="p-3.5 rounded-xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-900 dark:text-amber-200 text-xs space-y-2 text-left">
|
||||
<div class="flex items-center space-x-2 font-bold text-amber-800 dark:text-amber-300">
|
||||
<i data-lucide="shield-alert" class="w-4 h-4 flex-shrink-0"></i>
|
||||
<span>Nieuwe inloglocatie gedetecteerd</span>
|
||||
</div>
|
||||
<p class="text-[11px] leading-relaxed text-amber-700 dark:text-amber-300/90">
|
||||
U logt in vanaf een nieuw netwerkadres (<code class="font-mono bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded text-[10px]"><?= htmlspecialchars($_SESSION['pending_new_ip']) ?></code>).
|
||||
Voer uw 2FA Authenticator code in om uw identiteit te bevestigen en deze locatie automatisch te autoriseren.
|
||||
</p>
|
||||
<div class="pt-1.5 flex items-center justify-between text-[11px] border-t border-amber-200/60 dark:border-amber-800/40 text-amber-700 dark:text-amber-400">
|
||||
<span class="flex items-center space-x-1">
|
||||
<i data-lucide="mail-check" class="w-3.5 h-3.5"></i>
|
||||
<span><?= !empty($_SESSION['pending_new_ip_mail_sent']) ? 'Verificatiemail verzonden' : '2FA verificatie actief' ?></span>
|
||||
</span>
|
||||
<a href="/auth/login.php?action=resend_ip_mail" class="font-bold underline hover:text-amber-900 dark:hover:text-amber-200 cursor-pointer">
|
||||
Mail opnieuw sturen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-emerald-500/10 text-emerald-400 mb-2">
|
||||
<i data-lucide="key" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<p class="text-sm text-slate-800 dark:text-slate-300 font-semibold">
|
||||
<p className="text-sm text-slate-300 font-medium">
|
||||
Voer uw 6-cijferige verificatiecode in
|
||||
</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Gekoppeld aan <strong class="text-slate-900 dark:text-slate-200"><?= htmlspecialchars($_SESSION['pending_2fa_user_email'] ?? '') ?></strong>
|
||||
<p class="text-xs text-slate-400 mt-1">
|
||||
Gekoppeld aan <strong class="text-slate-200"><?= htmlspecialchars($_SESSION['pending_2fa_user_email'] ?? '') ?></strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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">
|
||||
<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-800/80 border border-slate-700 rounded-xl py-3 text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
|
||||
<span class="text-[11px] text-slate-500 text-center block mt-1">
|
||||
Tip: U kunt ook een 8-cijferige herstelcode invoeren.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<a href="/auth/login.php?action=restart" class="w-1/3 text-center bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-medium py-2.5 px-3 rounded-xl transition text-sm flex items-center justify-center cursor-pointer">
|
||||
<a href="/auth/login.php?action=restart" class="w-1/3 text-center bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium py-2.5 px-3 rounded-xl transition text-sm flex items-center justify-center">
|
||||
Terug
|
||||
</a>
|
||||
<button type="submit" class="w-2/3 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">
|
||||
<button type="submit" class="w-2/3 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<span>Inloggen</span>
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
@@ -417,17 +201,14 @@ $pageTitle = "Fociz — Inloggen";
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center text-xs text-slate-500">
|
||||
<div class="mt-8 pt-6 border-t border-slate-800/80 text-center text-xs text-slate-500">
|
||||
Toegang tot Fociz is strikt op uitnodiging. Vraag uw beheerder om een uitnodigingslink.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
try {
|
||||
localStorage.removeItem('fociz_session_last_activity');
|
||||
localStorage.removeItem('fociz_session_logged_out');
|
||||
} catch(e) {}
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -6,25 +6,6 @@
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
start_secure_session();
|
||||
|
||||
// Vernietig persistente login en sessie in database (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
$uid = (int)$_SESSION['user_id'];
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_remember_token_columns($db);
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmt->execute([$uid]);
|
||||
|
||||
fociz_terminate_session(session_id());
|
||||
fociz_log_audit($uid, 'SESSION_TERMINATED_LOGOUT', 'users', $uid, ['ip' => fociz_get_client_ip()], 'SUCCESS', $_SESSION['user_email'] ?? null);
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren bij uitloggen
|
||||
}
|
||||
}
|
||||
|
||||
// Verwijder remember cookie
|
||||
clear_remember_cookie();
|
||||
|
||||
// Vernietig alle sessie variabelen
|
||||
$_SESSION = [];
|
||||
|
||||
|
||||
@@ -9,51 +9,35 @@ require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
$user = require_auth();
|
||||
$db = get_db_connection();
|
||||
|
||||
// Haal centrale Microsoft app configuratie op (env of admin instellingen)
|
||||
$appConfig = microsoft_get_app_config();
|
||||
// Haal eventueel opgeslagen client_id en tenant_id op uit database
|
||||
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||
$stmt->execute([$user['id']]);
|
||||
$row = $stmt->fetch();
|
||||
$savedSettings = json_decode($row['settings'] ?? '{}', true) ?: [];
|
||||
|
||||
$clientId = !empty($_GET['client_id']) ? trim($_GET['client_id']) : $appConfig['client_id'];
|
||||
$tenantId = !empty($_GET['tenant_id']) ? trim($_GET['tenant_id']) : $appConfig['tenant_id'];
|
||||
$clientId = !empty($_GET['client_id'])
|
||||
? trim($_GET['client_id'])
|
||||
: (!empty($savedSettings['client_id']) ? $savedSettings['client_id'] : fociz_env('MICROSOFT_CLIENT_ID', ''));
|
||||
|
||||
// Voorkom AADSTS50194: single-tenant Azure applicaties mogen NOOIT het /common endpoint gebruiken
|
||||
if (empty($tenantId) || $tenantId === 'common') {
|
||||
$userEmail = $user['email'] ?? '';
|
||||
$parts = explode('@', $userEmail);
|
||||
$domain = strtolower(trim(end($parts)));
|
||||
if (!empty($domain) && !in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) {
|
||||
$tenantId = $domain;
|
||||
} else {
|
||||
$tenantId = 'planningsagenda.nl';
|
||||
}
|
||||
}
|
||||
$tenantId = !empty($_GET['tenant_id'])
|
||||
? trim($_GET['tenant_id'])
|
||||
: (!empty($savedSettings['tenant_id']) ? $savedSettings['tenant_id'] : fociz_env('MICROSOFT_TENANT_ID', 'common'));
|
||||
|
||||
if (empty($clientId)) {
|
||||
$err = ($user['role'] === 'ADMIN')
|
||||
? 'Vul eerst de Application (Client) ID en het Client Secret in bij het Systeembeheer om te koppelen met Microsoft 365.'
|
||||
: 'Microsoft 365 is nog niet geconfigureerd door de beheerder van uw organisatie. Vraag uw beheerder om de koppeling in te stellen.';
|
||||
header('Location: /profile.php?error=' . urlencode($err));
|
||||
header('Location: /integrations.php?error=missing_ms_client_id');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Bepaal de dynamische of geconfigureerde redirect URI
|
||||
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
|
||||
|
||||
// Genereer cryptografisch ondertekende OAuth state waarin de user_id en timestamp veilig zijn opgeslagen
|
||||
$state = generate_oauth_state((int)$user['id']);
|
||||
// Genereer unieke CSRF state
|
||||
$state = bin2hex(random_bytes(16));
|
||||
$_SESSION['ms_oauth_state'] = $state;
|
||||
$_SESSION['ms_oauth_user_id'] = (int)$user['id'];
|
||||
|
||||
// Onthoud eventuele gewenste terugkeerpagina (bijv. /mail.php)
|
||||
if (!empty($_GET['return_to'])) {
|
||||
$_SESSION['ms_oauth_return_to'] = trim($_GET['return_to']);
|
||||
} else {
|
||||
unset($_SESSION['ms_oauth_return_to']);
|
||||
}
|
||||
|
||||
$forceConsent = !empty($_GET['reconsent']) || !empty($_GET['force']);
|
||||
$_SESSION['ms_oauth_user_id'] = $user['id'];
|
||||
|
||||
// Stuur gebruiker door naar Microsoft login
|
||||
$authUrl = microsoft_get_auth_url($clientId, $redirectUri, $tenantId, $state, $forceConsent);
|
||||
$authUrl = microsoft_get_auth_url($clientId, $redirectUri, $tenantId, $state);
|
||||
header("Location: {$authUrl}");
|
||||
exit;
|
||||
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Wachtwoord Opnieuw Instellen
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
start_secure_session();
|
||||
|
||||
// Indien al ingelogd, direct doorsturen naar de planner
|
||||
if (get_logged_in_user()) {
|
||||
header('Location: /planner.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = trim($_GET['token'] ?? ($_POST['token'] ?? ''));
|
||||
$db = get_db_connection();
|
||||
|
||||
// Valideer token
|
||||
$resetRecord = null;
|
||||
if (!empty($token)) {
|
||||
$stmt = $db->prepare("
|
||||
SELECT pr.*, u.name, u.email as user_email
|
||||
FROM password_resets pr
|
||||
JOIN users u ON pr.email = u.email
|
||||
WHERE pr.token = ? AND pr.used_at IS NULL AND pr.expires_at > NOW()
|
||||
");
|
||||
$stmt->execute([$token]);
|
||||
$resetRecord = $stmt->fetch();
|
||||
}
|
||||
|
||||
$error = null;
|
||||
$success = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $resetRecord) {
|
||||
$password = $_POST['password'] ?? '';
|
||||
$confirmPassword = $_POST['confirm_password'] ?? '';
|
||||
|
||||
if (empty($password)) {
|
||||
$error = "Vul een nieuw wachtwoord in.";
|
||||
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
|
||||
$error = $pwErr;
|
||||
} elseif ($password !== $confirmPassword) {
|
||||
$error = "De wachtwoorden komen niet overeen.";
|
||||
} else {
|
||||
try {
|
||||
$db->beginTransaction();
|
||||
|
||||
$newHash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
// 1. Update wachtwoord van de gebruiker en reset remember tokens
|
||||
$stmtUser = $db->prepare("
|
||||
UPDATE users
|
||||
SET password_hash = ?, remember_token = NULL, remember_token_expires_at = NULL
|
||||
WHERE email = ?
|
||||
");
|
||||
$stmtUser->execute([$newHash, $resetRecord['user_email']]);
|
||||
|
||||
// 2. Markeer reset token als verbruikt
|
||||
$stmtToken = $db->prepare("UPDATE password_resets SET used_at = NOW() WHERE token = ?");
|
||||
$stmtToken->execute([$token]);
|
||||
|
||||
$db->commit();
|
||||
$success = true;
|
||||
|
||||
fociz_log_audit(null, 'PASSWORD_RESET_COMPLETED', 'user', null, ['email' => $resetRecord['user_email']], 'SUCCESS', $resetRecord['user_email']);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
error_log("Fout bij resetten wachtwoord: " . $e->getMessage());
|
||||
$error = "Er is een fout opgetreden bij het opslaan van uw nieuwe wachtwoord.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = "Fociz — Wachtwoord Opnieuw Instellen";
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<script>
|
||||
(function() {
|
||||
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
|
||||
var isDark = true;
|
||||
if (activeTheme === 'dark') isDark = true;
|
||||
else if (activeTheme === 'light') isDark = false;
|
||||
else if (activeTheme === 'system') {
|
||||
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
})();
|
||||
</script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: {
|
||||
850: '#151f32',
|
||||
950: '#070b12',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
|
||||
|
||||
<div class="w-full max-w-md bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
|
||||
|
||||
<?php if (!$resetRecord && !$success): ?>
|
||||
<!-- Ongeldige of Verlopen Link -->
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-rose-50 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-800/60 text-rose-600 dark:text-rose-400 mb-4 shadow-xs">
|
||||
<i data-lucide="shield-alert" class="w-7 h-7"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-black tracking-tight text-slate-900 dark:text-white mb-2">Herstellink Ongeldig</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-6 leading-relaxed">
|
||||
Deze wachtwoordherstellink is ongeldig, verlopen (langer dan 60 minuten geleden aangevraagd) of al eerder gebruikt.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<a href="/auth/forgot-password.php" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||
<span>Nieuwe herstellink aanvragen</span>
|
||||
</a>
|
||||
<a href="/auth/login.php" class="block text-center text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 font-medium py-2">
|
||||
Naar Inloggen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php elseif ($success): ?>
|
||||
<!-- Succesvol Gewijzigd -->
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/60 text-emerald-600 dark:text-emerald-400 mb-4 shadow-xs">
|
||||
<i data-lucide="check-circle" class="w-7 h-7"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-black tracking-tight text-slate-900 dark:text-white mb-2">Wachtwoord Gewijzigd!</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-6 leading-relaxed">
|
||||
Uw nieuwe wachtwoord is succesvol opgeslagen. U kunt nu direct inloggen in Fociz.
|
||||
</p>
|
||||
<a href="/auth/login.php" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||
<span>Inloggen met Nieuw Wachtwoord</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Formulier: Nieuw Wachtwoord Invoeren -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
|
||||
<i data-lucide="lock" class="w-7 h-7"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-black tracking-tight text-slate-900 dark:text-white">Nieuw Wachtwoord</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs mt-1">
|
||||
Kies een nieuw veilig wachtwoord voor <strong class="text-slate-800 dark:text-slate-200"><?= htmlspecialchars($resetRecord['user_email']) ?></strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="mb-5 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
|
||||
<span><?= htmlspecialchars($error) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="/auth/reset-password.php" class="space-y-4">
|
||||
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Nieuw wachtwoord (min. 12 tekens)</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="pwd"
|
||||
required
|
||||
minlength="12"
|
||||
autofocus
|
||||
placeholder="Minimaal 12 tekens"
|
||||
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition"
|
||||
oninput="checkPasswordStrength(this.value)"
|
||||
>
|
||||
</div>
|
||||
<!-- Sterkte Indicator -->
|
||||
<div class="mt-2 flex items-center space-x-1.5">
|
||||
<div id="bar1" class="h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors"></div>
|
||||
<div id="bar2" class="h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors"></div>
|
||||
<div id="bar3" class="h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors"></div>
|
||||
<span id="strengthText" class="text-[10px] text-slate-400 pl-1 font-medium"></span>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-400 dark:text-slate-500 mt-1">Minimaal 12 tekens (incl. hoofdletter, kleine letter, cijfer en speciaal teken).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig nieuw wachtwoord</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
required
|
||||
minlength="12"
|
||||
placeholder="Herhaal wachtwoord (min. 12 tekens)"
|
||||
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||
<i data-lucide="check" class="w-4 h-4"></i>
|
||||
<span>Wachtwoord Opslaan</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/auth/login.php" class="text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 font-medium">
|
||||
Annuleren en terug naar inloggen
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center text-xs text-slate-500">
|
||||
Beveiligd met end-to-end authenticatie • Fociz 2026
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
function checkPasswordStrength(val) {
|
||||
var b1 = document.getElementById('bar1');
|
||||
var b2 = document.getElementById('bar2');
|
||||
var b3 = document.getElementById('bar3');
|
||||
var txt = document.getElementById('strengthText');
|
||||
if (!b1 || !b2 || !b3 || !txt) return;
|
||||
|
||||
b1.className = 'h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors';
|
||||
b2.className = 'h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors';
|
||||
b3.className = 'h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors';
|
||||
txt.textContent = '';
|
||||
|
||||
if (!val) return;
|
||||
|
||||
var score = 0;
|
||||
if (val.length >= 12) score++;
|
||||
if (/[A-Z]/.test(val) && /[a-z]/.test(val) && /[0-9]/.test(val)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(val) && val.length >= 12) score++;
|
||||
|
||||
if (score === 1) {
|
||||
b1.className = 'h-1 flex-1 rounded-full bg-amber-500';
|
||||
txt.textContent = 'Matig';
|
||||
txt.className = 'text-[10px] text-amber-500 pl-1 font-medium';
|
||||
} else if (score === 2) {
|
||||
b1.className = 'h-1 flex-1 rounded-full bg-indigo-500';
|
||||
b2.className = 'h-1 flex-1 rounded-full bg-indigo-500';
|
||||
txt.textContent = 'Goed';
|
||||
txt.className = 'text-[10px] text-indigo-400 pl-1 font-medium';
|
||||
} else if (score >= 3) {
|
||||
b1.className = 'h-1 flex-1 rounded-full bg-emerald-500';
|
||||
b2.className = 'h-1 flex-1 rounded-full bg-emerald-500';
|
||||
b3.className = 'h-1 flex-1 rounded-full bg-emerald-500';
|
||||
txt.textContent = 'Sterk';
|
||||
txt.className = 'text-[10px] text-emerald-400 pl-1 font-medium';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -35,8 +35,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$error = "Alle velden (inclusief de 2FA verificatiecode) zijn verplicht.";
|
||||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$error = "Voer een geldig e-mailadres in.";
|
||||
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
|
||||
$error = $pwErr;
|
||||
} elseif (strlen($password) < 8) {
|
||||
$error = "Het wachtwoord moet minimaal 8 tekens bevatten.";
|
||||
} elseif ($password !== $confirmPassword) {
|
||||
$error = "De ingevoerde wachtwoorden komen niet overeen.";
|
||||
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
|
||||
@@ -62,31 +62,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$adminId = (int)$db->lastInsertId();
|
||||
|
||||
// Automatische dagsessie starten met verse sessie ID (OWASP ASVS v4.0 §3.3.1)
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
// Automatische sessie starten
|
||||
$_SESSION['user_id'] = $adminId;
|
||||
$_SESSION['user_name'] = $name;
|
||||
$_SESSION['user_email'] = $email;
|
||||
$_SESSION['user_role'] = 'ADMIN';
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
clear_remember_cookie();
|
||||
issue_day_session_token($adminId);
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session($adminId, $newSessionId, $maxIdleSeconds);
|
||||
unset($_SESSION['setup_totp_secret']);
|
||||
|
||||
fociz_log_audit($adminId, 'INITIAL_ADMIN_SETUP', 'user', $adminId, ['email' => $email, 'name' => $name], 'SUCCESS', $email);
|
||||
|
||||
$success = true;
|
||||
} catch (Exception $e) {
|
||||
$error = "Fout bij aanmaken beheerder in MariaDB: " . $e->getMessage();
|
||||
@@ -104,59 +87,28 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage -->
|
||||
<script>
|
||||
(function() {
|
||||
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
|
||||
var isDark = true;
|
||||
if (activeTheme === 'dark') isDark = true;
|
||||
else if (activeTheme === 'light') isDark = false;
|
||||
else if (activeTheme === 'system') {
|
||||
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
})();
|
||||
</script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: { 850: '#151f32', 950: '#070b12' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>body { font-family: 'Plus Jakarta Sans', sans-serif; }</style>
|
||||
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
|
||||
|
||||
<div class="w-full max-w-xl bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
|
||||
<div class="w-full max-w-xl bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
|
||||
<i data-lucide="shield-check" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Fociz Initialisatie</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-white">Fociz Initialisatie</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Eenmalige configuratie van het hoofdbeheerdersaccount met verplichte 2FA
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="mb-6 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
|
||||
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
|
||||
<span><?= htmlspecialchars($error) ?></span>
|
||||
</div>
|
||||
@@ -165,26 +117,26 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
<?php if ($success): ?>
|
||||
<!-- Stap 3: Voltooid met backup codes -->
|
||||
<div class="text-center space-y-6">
|
||||
<div class="w-16 h-16 bg-teal-50 dark:bg-teal-950/40 text-teal-600 dark:text-teal-400 rounded-2xl flex items-center justify-center mx-auto border border-teal-200 dark:border-teal-800/60 shadow-xs">
|
||||
<div class="w-16 h-16 bg-emerald-500/20 text-emerald-400 rounded-full flex items-center justify-center mx-auto border border-emerald-500/40">
|
||||
<i data-lucide="check-circle-2" class="w-10 h-10"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-1">Beheerder Geïnstalleerd!</h2>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm">
|
||||
<h2 class="text-2xl font-bold text-white mb-1">Beheerder Geïnstalleerd!</h2>
|
||||
<p class="text-slate-400 text-sm">
|
||||
Uw account is aangemaakt en direct beveiligd met 2FA. Bewaar deze eenmalige herstelcodes veilig:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-2xl p-4 text-left">
|
||||
<div class="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-2">HERSTELCODES (NOODGEBRUIK):</div>
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-xs text-slate-800 dark:text-slate-300">
|
||||
<div class="bg-slate-950/90 border border-slate-800 rounded-xl p-4 text-left">
|
||||
<div class="text-xs font-semibold text-slate-400 mb-2">HERSTELCODES (NOODGEBRUIK):</div>
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-xs text-slate-300">
|
||||
<?php foreach ($backupCodes as $code): ?>
|
||||
<span class="bg-white dark:bg-[#111622] px-2.5 py-1.5 rounded-xl border border-slate-200 dark:border-slate-800 text-center font-bold"><?= htmlspecialchars($code) ?></span>
|
||||
<span class="bg-slate-900 px-2.5 py-1.5 rounded border border-slate-800 text-center"><?= htmlspecialchars($code) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/planner.php" class="inline-flex items-center justify-center w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs space-x-2 cursor-pointer">
|
||||
<a href="/planner.php" class="inline-flex items-center justify-center w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 space-x-2">
|
||||
<span>Start met Fociz Dagplanning</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
@@ -196,54 +148,54 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
<!-- Account Info -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Volledige naam</label>
|
||||
<input type="text" name="name" required placeholder="Bijv. Jan de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Volledige naam</label>
|
||||
<input type="text" name="name" required placeholder="Bijv. Jan de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beheerder E-mailadres</label>
|
||||
<input type="email" name="email" required placeholder="admin@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Beheerder E-mailadres</label>
|
||||
<input type="email" name="email" required placeholder="admin@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 12 tekens)</label>
|
||||
<input type="password" name="password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA QR Code & Koppeling -->
|
||||
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wider flex items-center justify-center space-x-1.5 text-indigo-600 dark:text-indigo-400">
|
||||
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center justify-center space-x-1.5 text-emerald-400">
|
||||
<i data-lucide="key" class="w-4 h-4"></i>
|
||||
<span>Tweestapsverificatie (2FA) Koppelen</span>
|
||||
</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 max-w-md mx-auto">
|
||||
<p class="text-xs text-slate-400 max-w-md mx-auto">
|
||||
Scan onderstaande QR-code met uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
|
||||
</p>
|
||||
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
|
||||
<div id="qrcode" class="w-44 h-44 mx-auto flex items-center justify-center"></div>
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xl">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-44 h-44 mx-auto">
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
|
||||
<span>Sleutel: <strong class="text-indigo-600 dark:text-indigo-400"><?= htmlspecialchars($secret) ?></strong></span>
|
||||
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
|
||||
<span>Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1.5">
|
||||
Voer de 6-cijferige verificatiecode uit uw app in:
|
||||
</label>
|
||||
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-2 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
|
||||
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-6 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<span>Account & 2FA Bevestigen</span>
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
@@ -252,20 +204,8 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/qrcode.min.js"></script>
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
const qrContainer = document.getElementById('qrcode');
|
||||
if (qrContainer) {
|
||||
new QRCode(qrContainer, {
|
||||
text: <?= json_encode($otpAuthUrl) ?>,
|
||||
width: 176,
|
||||
height: 176,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Nieuwe Inloglocatie / IP Verificatie
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
start_secure_session();
|
||||
|
||||
$token = trim($_GET['token'] ?? '');
|
||||
$db = get_db_connection();
|
||||
|
||||
$error = null;
|
||||
$success = false;
|
||||
$userEmail = '';
|
||||
$ipAddress = '';
|
||||
|
||||
if (empty($token) || !preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||
$error = "Ongeldige of ontbrekende verificatielink.";
|
||||
} else {
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT kip.*, u.email, u.name
|
||||
FROM user_known_ips kip
|
||||
JOIN users u ON kip.user_id = u.id
|
||||
WHERE kip.verify_token = ?
|
||||
");
|
||||
$stmt->execute([$token]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
if (!$record) {
|
||||
$error = "Deze verificatielink is niet gevonden of al gebruikt.";
|
||||
} elseif (!empty($record['verify_token_expires_at']) && strtotime($record['verify_token_expires_at']) < time()) {
|
||||
$error = "Deze verificatielink is verlopen (geldigheidsduur was 30 minuten). Probeer opnieuw in te loggen om een nieuwe link te ontvangen.";
|
||||
} else {
|
||||
// Verifieer het IP-adres
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE user_known_ips
|
||||
SET is_verified = 1, verify_token = NULL, verify_token_expires_at = NULL, last_seen_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$updateStmt->execute([$record['id']]);
|
||||
|
||||
$success = true;
|
||||
$userEmail = $record['email'];
|
||||
$ipAddress = $record['ip_address'];
|
||||
fociz_log_audit((int)$record['user_id'], 'IP_VERIFIED', 'user_known_ips', (int)$record['id'], ['ip' => $ipAddress], 'SUCCESS', $userEmail);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("verify-ip.php error: " . $e->getMessage());
|
||||
$error = "Er is een databasefout opgetreden bij het verifiëren.";
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = "Fociz — Locatie Verificatie";
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
indigo: { 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body class="bg-slate-100 dark:bg-[#0c0f17] text-slate-800 dark:text-slate-100 min-h-screen flex items-center justify-center p-4 transition-colors duration-200">
|
||||
|
||||
<div class="w-full max-w-md bg-white dark:bg-[#121622] rounded-2xl border border-slate-200/80 dark:border-slate-800/80 shadow-2xl p-8 space-y-6">
|
||||
|
||||
<!-- Logo & Header -->
|
||||
<div class="text-center space-y-2">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-500/10 border border-indigo-500/20 text-indigo-500 mb-2">
|
||||
<i data-lucide="shield-check" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-slate-900 dark:text-white">Locatie Verificatie</h1>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">Beveiligde inlogtoegang voor Fociz</p>
|
||||
</div>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="p-4 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-800 dark:text-emerald-400 text-sm space-y-2 text-center">
|
||||
<div class="flex items-center justify-center space-x-2 font-bold text-emerald-700 dark:text-emerald-300">
|
||||
<i data-lucide="check-circle-2" class="w-5 h-5"></i>
|
||||
<span>Locatie Succesvol Geverifieerd!</span>
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
Het IP-adres <strong class="font-mono"><?= htmlspecialchars($ipAddress) ?></strong> is nu geautoriseerd voor uw account (<?= htmlspecialchars($userEmail) ?>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<a href="/auth/login.php?ip_verified=1" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<span>Direct Inloggen</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 text-rose-800 dark:text-rose-400 text-sm space-y-2 text-center">
|
||||
<div class="flex items-center justify-center space-x-2 font-bold text-rose-700 dark:text-rose-300">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||
<span>Verificatie Mislukt</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed">
|
||||
<?= htmlspecialchars($error ?? 'Onbekende fout.') ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<a href="/auth/login.php" class="w-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold py-2.5 px-4 rounded-xl transition flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
<span>Terug naar Inloggen</span>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,23 +3,15 @@
|
||||
* Fociz Brainstorm Sessies Overzicht
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
require_once __DIR__ . '/includes/settings.php';
|
||||
$currentUser = require_auth();
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
$db = get_db_connection();
|
||||
$brainstormCategories = get_brainstorm_categories();
|
||||
|
||||
// Verwerk nieuwe sessie aanmaken
|
||||
$error = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_session') {
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$theme = trim($_POST['category'] ?? ($_POST['theme'] ?? ''));
|
||||
if (empty($theme) && !empty($brainstormCategories)) {
|
||||
$firstActive = array_values(array_filter($brainstormCategories, fn($c) => !empty($c['is_active'])))[0] ?? null;
|
||||
$theme = $firstActive ? $firstActive['name'] : 'Product & Strategie';
|
||||
}
|
||||
$theme = trim($_POST['theme'] ?? 'Product & Strategie');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$autoAi = !empty($_POST['auto_ai']) ? 1 : 0;
|
||||
|
||||
if (!empty($title)) {
|
||||
$stmt = $db->prepare("
|
||||
@@ -28,8 +20,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
||||
");
|
||||
$stmt->execute([$title, $theme, $description, $currentUser['id']]);
|
||||
$newId = $db->lastInsertId();
|
||||
$redirectUrl = "/brainstorm_canvas.php?id={$newId}" . ($autoAi ? "&auto_ai=1" : "");
|
||||
header("Location: {$redirectUrl}");
|
||||
header("Location: /brainstorm_canvas.php?id={$newId}");
|
||||
exit;
|
||||
} else {
|
||||
$error = "Titel van de brainstormsessie is verplicht.";
|
||||
@@ -37,47 +28,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
||||
}
|
||||
|
||||
// Haal alle sessies op
|
||||
$sessions = [];
|
||||
try {
|
||||
$stmtSessions = $db->query("
|
||||
$stmtSessions = $db->query("
|
||||
SELECT s.*, u.name as creator_name,
|
||||
(SELECT COUNT(*) FROM brainstorm_cards WHERE session_id = s.id) as cards_count
|
||||
FROM brainstorm_sessions s
|
||||
JOIN users u ON s.created_by_id = u.id
|
||||
ORDER BY s.created_at DESC
|
||||
");
|
||||
$sessions = $stmtSessions->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen brainstorm sessies: " . $e->getMessage());
|
||||
ensure_tables_exist($db);
|
||||
}
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
");
|
||||
$sessions = $stmtSessions->fetchAll();
|
||||
?>
|
||||
|
||||
<div class="space-y-6 max-w-7xl mx-auto">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="fociz-card p-6 sm:p-7 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<div class="flex items-center space-x-2 text-amber-400 text-xs font-semibold uppercase tracking-wider mb-1">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4"></i>
|
||||
<span>Creatie & Innovatie</span>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
Brainstorm Sessies
|
||||
</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
||||
<p class="text-slate-400 text-xs sm:text-sm mt-1">
|
||||
Brainstorm met collega's, verzamel ideeën en knelpunten, en transformeer ze direct tot een werkbare roadmap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs flex-shrink-0 cursor-pointer">
|
||||
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-amber-500 hover:bg-amber-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-amber-500/20 flex-shrink-0">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
<span>Nieuwe Sessie Starten</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-700 dark:text-rose-400 text-sm">
|
||||
<div class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||
<?= htmlspecialchars($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -85,163 +69,97 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<!-- Grid met sessies -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<?php if (empty($sessions)): ?>
|
||||
<div class="col-span-full fociz-card p-12 text-center shadow-xs">
|
||||
<i data-lucide="lightbulb" class="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white">Nog geen brainstormsessies gestart</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
<div class="col-span-full bg-slate-900/60 border border-slate-800/80 rounded-2xl p-12 text-center">
|
||||
<i data-lucide="lightbulb" class="w-12 h-12 text-amber-500/40 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold text-white">Nog geen brainstormsessies gestart</h3>
|
||||
<p class="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
Start uw eerste brainstormsessie om ideeën te verzamelen en ze met één klik om te vormen naar planningsitems.
|
||||
</p>
|
||||
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl text-xs transition cursor-pointer shadow-xs">
|
||||
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="mt-4 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold rounded-xl text-xs transition">
|
||||
Sessie Starten
|
||||
</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($sessions as $sess):
|
||||
$canDelete = ($currentUser['role'] === 'ADMIN' || (int)$sess['created_by_id'] === (int)$currentUser['id']);
|
||||
?>
|
||||
<div class="group fociz-card-interactive p-5 flex flex-col justify-between hover:border-indigo-400 dark:hover:border-indigo-500/60 relative">
|
||||
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="block flex-1">
|
||||
<?php foreach ($sessions as $sess): ?>
|
||||
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="group bg-slate-900/80 hover:bg-slate-850 border border-slate-800 hover:border-amber-500/40 rounded-2xl p-5 shadow-lg transition flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="px-2.5 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 text-indigo-700 dark:text-indigo-400 font-semibold text-[11px]">
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 font-semibold text-[11px]">
|
||||
<?= htmlspecialchars($sess['theme']) ?>
|
||||
</span>
|
||||
<span class="flex items-center space-x-1 text-slate-400 dark:text-slate-500 text-xs font-medium">
|
||||
<span class="flex items-center space-x-1 text-slate-500 text-xs">
|
||||
<i data-lucide="message-square" class="w-3.5 h-3.5"></i>
|
||||
<span><?= $sess['cards_count'] ?> kaarten</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition leading-snug">
|
||||
<h3 class="text-base font-bold text-white group-hover:text-amber-400 transition leading-snug">
|
||||
<?= htmlspecialchars($sess['title']) ?>
|
||||
</h3>
|
||||
|
||||
<?php if (!empty($sess['description'])): ?>
|
||||
<p class="text-slate-600 dark:text-slate-400 text-xs mt-2 line-clamp-2 leading-relaxed">
|
||||
<p class="text-slate-400 text-xs mt-2 line-clamp-2">
|
||||
<?= htmlspecialchars($sess['description']) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 pt-4 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||
<span class="truncate">Door <?= htmlspecialchars($sess['creator_name']) ?></span>
|
||||
<div class="flex items-center space-x-2 flex-shrink-0 ml-2">
|
||||
<?php if ($canDelete): ?>
|
||||
<button
|
||||
type="button"
|
||||
onclick="promptDeleteSession(event, <?= (int)$sess['id'] ?>, '<?= htmlspecialchars(addslashes($sess['title']), ENT_QUOTES) ?>', <?= (int)$sess['cards_count'] ?>)"
|
||||
class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition cursor-pointer"
|
||||
title="Sessie verwijderen"
|
||||
>
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="flex items-center space-x-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-bold">
|
||||
<div class="mt-5 pt-4 border-t border-slate-800/80 flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Door <?= htmlspecialchars($sess['creator_name']) ?></span>
|
||||
<div class="flex items-center space-x-1 text-amber-400 group-hover:translate-x-1 transition font-medium">
|
||||
<span>Open canvas</span>
|
||||
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nieuwe sessie -->
|
||||
<div id="newSessionModal" onclick="if(event.target === this) this.classList.add('hidden')" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-xl w-full my-6 space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-xl bg-indigo-50 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 flex items-center justify-center border border-indigo-100 dark:border-indigo-800/60">
|
||||
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Nieuwe Brainstormsessie</h3>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400">Creëer een canvas en voed de AI Co-Pilot met achtergrondinformatie</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
<div id="newSessionModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full">
|
||||
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
|
||||
<h3 class="text-base font-bold text-white flex items-center space-x-2">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-amber-400"></i>
|
||||
<span>Nieuwe Brainstormsessie</span>
|
||||
</h3>
|
||||
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="text-slate-400 hover:text-white">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/brainstorm.php" class="space-y-4">
|
||||
<input type="hidden" name="action" value="create_session">
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Onderwerp / Titel <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="title" required placeholder="Bijv. Modernisering Klantportaal & Self-service" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs font-semibold">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Thema / Categorie</label>
|
||||
<select name="category" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<?php
|
||||
$hasActiveCategory = false;
|
||||
foreach ($brainstormCategories as $bCat):
|
||||
if (isset($bCat['is_active']) && empty($bCat['is_active'])) continue;
|
||||
$hasActiveCategory = true;
|
||||
?>
|
||||
<option value="<?= htmlspecialchars($bCat['name']) ?>"><?= htmlspecialchars($bCat['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$hasActiveCategory): ?>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Onderwerp / Titel</label>
|
||||
<input type="text" name="title" required placeholder="Bijv. Q4 Automatisering & Klantreis" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Thema / Categorie</label>
|
||||
<select name="theme" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
|
||||
<option value="Product & Strategie">Product & Strategie</option>
|
||||
<option value="Procesverbetering">Procesverbetering</option>
|
||||
<option value="Klant & Service">Klant & Service</option>
|
||||
<option value="Technologie & IT">Technologie & IT</option>
|
||||
<option value="Marketing & Groei">Marketing & Groei</option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Uitgebreide Briefing, Context & Doelstellingen
|
||||
</label>
|
||||
<span class="text-[10px] text-indigo-600 dark:text-indigo-400 font-medium">Voeding voor AI post-its</span>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Doel / Beschrijving</label>
|
||||
<input type="text" name="description" placeholder="Doel van deze sessie..." class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none">
|
||||
</div>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="6"
|
||||
placeholder="Voer hier alle achtergrondinformatie, context en doelen in waar de AI post-its van gaat maken. Bijvoorbeeld: • Achtergrond: Klanten klagen over lange wachttijden bij onboarding. • Doel: Binnen 3 maanden de doorlooptijd met 50% verkorten. • Bekende knelpunten: Handmatige validatie, ontbrekende API-koppeling met CRM. • Gewenste richting: Self-service portaal, automatische validatie, duidelijke statusupdates."
|
||||
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3.5 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs leading-relaxed resize-y font-normal"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400 mt-1">
|
||||
Tip: Hoe gedetailleerder de informatie, hoe gerichter en waardevoller de ideeën, knelpunten en oplossingen die AI genereert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- AI Direct Genereren Optie -->
|
||||
<div class="p-3.5 rounded-xl bg-violet-50/70 dark:bg-violet-950/25 border border-violet-200/80 dark:border-violet-800/40 flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="auto_ai_checkbox"
|
||||
name="auto_ai"
|
||||
value="1"
|
||||
checked
|
||||
class="w-4 h-4 mt-0.5 rounded text-violet-600 focus:ring-0 border-violet-300 dark:border-violet-700 cursor-pointer"
|
||||
>
|
||||
<label for="auto_ai_checkbox" class="text-xs cursor-pointer select-none">
|
||||
<span class="font-bold text-violet-900 dark:text-violet-200 block flex items-center space-x-1.5">
|
||||
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-violet-600 dark:text-violet-400"></i>
|
||||
<span>Direct AI post-its genereren op basis van deze sessiebeschrijving</span>
|
||||
</span>
|
||||
<span class="text-[11px] text-violet-700/80 dark:text-violet-300/80 block mt-0.5 leading-snug">
|
||||
AI Co-Pilot analyseert de ingevoerde context direct bij het openen en maakt automatisch gerichte post-its aan voor Ideeën, Knelpunten, Oplossingen en Quick Wins.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button type="submit" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-2">
|
||||
<i data-lucide="layout-grid" class="w-4 h-4"></i>
|
||||
<span>Sessie Aanmaken & Canvas Openen</span>
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button type="submit" class="flex-1 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition">
|
||||
Sessie Aanmaken & Canvas Openen
|
||||
</button>
|
||||
<button type="button" onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
<button type="button" onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
@@ -249,110 +167,5 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Sessie Verwijderen Bevestiging -->
|
||||
<div id="deleteSessionModal" onclick="if(event.target === this) closeDeleteSessionModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-md w-full space-y-4">
|
||||
<div class="flex items-start space-x-3.5 pb-2">
|
||||
<div class="w-10 h-10 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 text-rose-600 dark:text-rose-400 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white">Brainstormsessie verwijderen</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Weet u zeker dat u deze sessie wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3.5 rounded-xl bg-rose-50/60 dark:bg-rose-950/20 border border-rose-200/80 dark:border-rose-900/40 text-xs text-rose-800 dark:text-rose-300 space-y-1">
|
||||
<p class="font-bold text-slate-900 dark:text-white" id="deleteSessionTitle">—</p>
|
||||
<p id="deleteSessionWarning" class="text-[11px] text-rose-700 dark:text-rose-400">
|
||||
Alle bijbehorende ideeën, knelpunten en kaarten worden definitief gewist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button type="button" onclick="closeDeleteSessionModal()" class="flex-1 px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
Annuleren
|
||||
</button>
|
||||
<button type="button" id="btnConfirmDeleteSession" onclick="executeDeleteSession()" class="flex-1 px-4 py-2.5 bg-rose-600 hover:bg-rose-500 text-white font-bold rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
<span>Verwijderen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sessionToDeleteId = null;
|
||||
|
||||
function promptDeleteSession(e, sessionId, sessionTitle, cardsCount) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
sessionToDeleteId = sessionId;
|
||||
document.getElementById('deleteSessionTitle').textContent = sessionTitle || 'Brainstormsessie';
|
||||
const warningEl = document.getElementById('deleteSessionWarning');
|
||||
if (cardsCount > 0) {
|
||||
warningEl.textContent = `Let op: Deze sessie bevat ${cardsCount} brainstormkaart(en). Alle kaarten worden definitief verwijderd.`;
|
||||
} else {
|
||||
warningEl.textContent = 'Alle inhoud van deze sessie wordt definitief gewist.';
|
||||
}
|
||||
const modal = document.getElementById('deleteSessionModal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeDeleteSessionModal() {
|
||||
sessionToDeleteId = null;
|
||||
const modal = document.getElementById('deleteSessionModal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
|
||||
async function executeDeleteSession() {
|
||||
if (!sessionToDeleteId) return;
|
||||
|
||||
const btn = document.getElementById('btnConfirmDeleteSession');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Verwijderen...</span>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/brainstorm.php?action=delete_session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionToDeleteId })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
closeDeleteSessionModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Fout bij verwijderen: ' + (data.error || 'Onbekende fout'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Netwerkfout bij verwijderen: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('newSessionModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
closeDeleteSessionModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"php": "^8.2",
|
||||
"ext-pdo": "*",
|
||||
"ext-pdo_mysql": "*",
|
||||
"ext-mysqli": "*"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# =========================================================================
|
||||
# FOCIZ CONFIGURATIE (Centraal)
|
||||
# De omgeving en database worden automatisch veilig bepaald op basis van het domein:
|
||||
# - dev.fociz.nl / localhost => development (focizdev / focizdev_user)
|
||||
# - www.fociz.nl / fociz.nl => live (fociz / focizlive_user)
|
||||
# Optionele serverspecifieke overrides kunnen in de root /.env worden geplaatst.
|
||||
# =========================================================================
|
||||
|
||||
# MariaDB Server
|
||||
DB_HOST="192.168.2.33"
|
||||
DB_PORT="3306"
|
||||
|
||||
# Applicatie Geheime Sleutel (voor tokenversleuteling & OAuth state)
|
||||
APP_SECRET="fociz-maria-db-super-secret-key-998877"
|
||||
|
||||
# Microsoft 365 Entra ID Tenant
|
||||
MICROSOFT_TENANT_ID="planningsagenda.nl"
|
||||
|
||||
# SMTP Mail Server
|
||||
SMTP_HOST="mail.fociz.nl"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="info@fociz.nl"
|
||||
SMTP_PASS="6swpQSBg5uWFmjar7F6a"
|
||||
SMTP_SECURE="tls"
|
||||
SMTP_FROM_EMAIL="info@fociz.nl"
|
||||
SMTP_FROM_NAME="Fociz Dev"
|
||||
SMTP_REPLY_TO="info@fociz.nl"
|
||||
|
||||
# Webhooks (optioneel)
|
||||
JIRA_WEBHOOK_SECRET="fociz_webhook_dev_2026"
|
||||
@@ -1,8 +0,0 @@
|
||||
# Beveiliging van de interne configuratiemap
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -1,46 +1,35 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Centrale Applicatie- en Databaseconfiguratie
|
||||
* Alle configuratie staat overzichtelijk en veilig in dit ene bestand.
|
||||
* Fociz Applicatie- en Databaseconfiguratie (MAIN / LIVE BRANCH)
|
||||
* Laadt automatisch .env (of .env.example indien .env niet bestaat)
|
||||
*/
|
||||
|
||||
// Blokkeer directe HTTP toegang tot dit configuratiebestand (Pen Test Beveiliging)
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === 'config.php') {
|
||||
http_response_code(403);
|
||||
exit('Toegang geweigerd.');
|
||||
}
|
||||
|
||||
// Stuur direct strenge beveiligingsheaders mee op elk verzoek (Pen Test Beveiliging)
|
||||
if (!headers_sent()) {
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: SAMEORIGIN');
|
||||
header('X-XSS-Protection: 1; mode=block');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://unpkg.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'");
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
}
|
||||
header_remove('X-Powered-By');
|
||||
}
|
||||
@ini_set('expose_php', 'off');
|
||||
|
||||
// 1. .env inlezing (controleert eerst server root .env en dan config/.env)
|
||||
$envVars = [];
|
||||
$envLocations = [__DIR__ . '/../.env', __DIR__ . '/.env'];
|
||||
foreach ($envLocations as $envFile) {
|
||||
|
||||
// 1. Zoek naar .env of val terug op .env.example
|
||||
$envCandidates = [
|
||||
__DIR__ . '/../.env',
|
||||
__DIR__ . '/../.env.example',
|
||||
];
|
||||
|
||||
foreach ($envCandidates as $envFile) {
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines !== false) {
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
// Negeer lege regels en commentaarregels (# of //)
|
||||
if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, '//')) {
|
||||
continue;
|
||||
}
|
||||
if (strpos($line, '=') !== false) {
|
||||
list($name, $value) = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
// Verwijder omlijstende aanhalingstekens (" of ')
|
||||
$value = trim($value, " \t\n\r\0\x0B\"'");
|
||||
|
||||
// Sla op in array en zet in globale PHP variabelen
|
||||
$envVars[$name] = $value;
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
@@ -48,7 +37,7 @@ foreach ($envLocations as $envFile) {
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // Eerste gevonden .env bestand is leidend
|
||||
break; // Stop zodra we het eerste geldige bestand hebben ingeladen
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,134 +64,30 @@ if (!function_exists('fociz_env')) {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. APPLICATIE INSTELLINGEN & OMGEVINGSDETECTIE
|
||||
// =========================================================================
|
||||
define('APP_NAME', 'Fociz');
|
||||
|
||||
// Automatische omgevingsdetectie:
|
||||
// 1. Hostname/domeinnaam is altijd leidend voor productiebeveiliging
|
||||
$detectedHost = strtolower($_SERVER['HTTP_HOST'] ?? '');
|
||||
$isDevHost = (
|
||||
str_contains($detectedHost, 'dev.') ||
|
||||
str_contains($detectedHost, 'localhost') ||
|
||||
str_contains($detectedHost, '127.0.0.1')
|
||||
);
|
||||
$isLiveHost = (
|
||||
!empty($detectedHost) &&
|
||||
!$isDevHost &&
|
||||
(str_contains($detectedHost, 'fociz.nl') || str_contains($detectedHost, 'live'))
|
||||
);
|
||||
|
||||
// Bepaal APP_ENV: op live domeinen (bijv. www.fociz.nl of fociz.nl) ALTIJD 'live'
|
||||
// zodat een per ongeluk meegekomen dev-configuratie NOOIT productie kan beïnvloeden.
|
||||
if ($isLiveHost) {
|
||||
$effectiveEnv = 'live';
|
||||
} elseif ($isDevHost) {
|
||||
$effectiveEnv = 'development';
|
||||
} else {
|
||||
$effectiveEnv = fociz_env('APP_ENV', 'live');
|
||||
}
|
||||
define('APP_ENV', $effectiveEnv);
|
||||
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
|
||||
|
||||
// =========================================================================
|
||||
// 3. CENTRALE DATABASE CONFIGURATIE (OMGEVINGSAFHANKELIJK & GIT-VEILIG)
|
||||
// =========================================================================
|
||||
$isDevEnv = (APP_ENV === 'development');
|
||||
// MariaDB Verbindingsgegevens (Live defaults voor main branch; overschrijfbaar via server .env)
|
||||
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
|
||||
define('DB_PORT', fociz_env('DB_PORT', '3306'));
|
||||
|
||||
// Veiligheidswaarborg: op het live domein of live omgeving NOOIT per ongeluk de dev database gebruiken
|
||||
if ($isLiveHost || APP_ENV === 'live') {
|
||||
$rawDbName = fociz_env('DB_NAME');
|
||||
$rawDbUser = fociz_env('DB_USER');
|
||||
$rawDbPass = fociz_env('DB_PASS');
|
||||
|
||||
define('DB_NAME', (!empty($rawDbName) && $rawDbName !== 'focizdev') ? $rawDbName : 'fociz');
|
||||
define('DB_USER', (!empty($rawDbUser) && $rawDbUser !== 'focizdev_user') ? $rawDbUser : 'focizlive_user');
|
||||
define('DB_PASS', (!empty($rawDbPass) && $rawDbPass !== 'F0c1z_Dev_Secure_2026!') ? $rawDbPass : 'F0c1z_Live_Secure_2026!');
|
||||
} else {
|
||||
define('DB_NAME', fociz_env('DB_NAME', 'focizdev'));
|
||||
define('DB_USER', fociz_env('DB_USER', 'focizdev_user'));
|
||||
define('DB_PASS', fociz_env('DB_PASS', 'F0c1z_Dev_Secure_2026!'));
|
||||
}
|
||||
define('DB_NAME', fociz_env('DB_NAME', 'fociz'));
|
||||
define('DB_USER', fociz_env('DB_USER', 'focizlive_user'));
|
||||
define('DB_PASS', fociz_env('DB_PASS', 'F0c1z_Live_Secure_2026!'));
|
||||
define('DB_CHARSET', 'utf8mb4');
|
||||
|
||||
// Bepaal de dynamische of geconfigureerde applicatie URL
|
||||
if (!function_exists('fociz_detect_app_url')) {
|
||||
function fociz_detect_app_url(): string {
|
||||
$envUrl = fociz_env('APP_URL');
|
||||
if (!empty($envUrl) && !str_contains($envUrl, 'uw-domein.nl')) {
|
||||
return rtrim($envUrl, '/');
|
||||
}
|
||||
|
||||
// Dynamische detectie op basis van het huidige domein (werkt direct op www.fociz.nl én dev.fociz.nl)
|
||||
if (!empty($_SERVER['HTTP_HOST'])) {
|
||||
$isHttps = (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
||||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') ||
|
||||
(!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] === 'on') ||
|
||||
(isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443)
|
||||
);
|
||||
$scheme = $isHttps ? 'https://' : 'http://';
|
||||
return $scheme . $_SERVER['HTTP_HOST'];
|
||||
}
|
||||
|
||||
return (defined('APP_ENV') && APP_ENV === 'live') ? 'https://www.fociz.nl' : 'https://dev.fociz.nl';
|
||||
}
|
||||
}
|
||||
define('APP_URL', fociz_detect_app_url());
|
||||
// Applicatie instellingen (Live defaults)
|
||||
define('APP_NAME', 'Fociz');
|
||||
define('APP_URL', fociz_env('APP_URL', ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'fociz.nl'))));
|
||||
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
|
||||
define('APP_ENV', fociz_env('APP_ENV', 'live')); // 'live' voor de main branch
|
||||
|
||||
// Tijdzone
|
||||
date_default_timezone_set('Europe/Amsterdam');
|
||||
|
||||
// =========================================================================
|
||||
// 3b. SMTP MAIL SERVER CONFIGURATIE
|
||||
// =========================================================================
|
||||
define('SMTP_HOST', fociz_env('SMTP_HOST', 'mail.fociz.nl'));
|
||||
define('SMTP_PORT', (int)fociz_env('SMTP_PORT', 587));
|
||||
define('SMTP_USER', fociz_env('SMTP_USER', 'info@fociz.nl'));
|
||||
define('SMTP_PASS', fociz_env('SMTP_PASS', '6swpQSBg5uWFmjar7F6a'));
|
||||
define('SMTP_SECURE', fociz_env('SMTP_SECURE', 'tls')); // 'tls' (STARTTLS port 587) of 'ssl' (port 465)
|
||||
define('SMTP_FROM_EMAIL', fociz_env('SMTP_FROM_EMAIL', 'info@fociz.nl'));
|
||||
define('SMTP_FROM_NAME', fociz_env('SMTP_FROM_NAME', 'Fociz'));
|
||||
define('SMTP_REPLY_TO', fociz_env('SMTP_REPLY_TO', 'info@fociz.nl'));
|
||||
|
||||
// =========================================================================
|
||||
// 4. VEILIGE FOUTAFHANDELING & PEN TEST BESCHERMING
|
||||
// =========================================================================
|
||||
if (APP_ENV === 'live') {
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('display_startup_errors', '0');
|
||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);
|
||||
|
||||
// Registreer een nette noodfoutpagina voor onverhoopte fatale PHP fouten
|
||||
register_shutdown_function(function() {
|
||||
$error = error_get_last();
|
||||
if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
|
||||
error_log("Fociz Fatal Error: [{$error['type']}] {$error['message']} in {$error['file']}:{$error['line']}");
|
||||
$showDiagnostics = (isset($_SESSION['user_role']) && $_SESSION['user_role'] === 'ADMIN');
|
||||
if ($showDiagnostics) {
|
||||
echo "<div style='font-family: monospace; background: #0c0f17; color: #ef4444; padding: 25px; margin: 20px; border-radius: 12px; border: 1px solid #dc2626;'>
|
||||
<h3>⚠️ Fociz Systeemfout (Diagnostiek)</h3>
|
||||
<p><strong>Fout:</strong> " . htmlspecialchars($error['message']) . "</p>
|
||||
<p><strong>Bestand:</strong> " . htmlspecialchars($error['file']) . " op regel " . $error['line'] . "</p>
|
||||
</div>";
|
||||
} else {
|
||||
echo "<div style='font-family: sans-serif; background: #0c0f17; color: #f8fafc; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='max-width: 480px; text-align: center; padding: 40px;'>
|
||||
<div style='width: 48px; height: 48px; margin: 0 auto 16px; border-radius: 12px; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); display: flex; align-items: center; justify-content: center; color: #818cf8; font-weight: bold;'>F</div>
|
||||
<h2 style='font-size: 20px; margin-bottom: 8px;'>Er is iets misgegaan</h2>
|
||||
<p style='color: #94a3b8; font-size: 14px; line-height: 1.5;'>De pagina kon niet worden geladen. Probeer de pagina te vernieuwen of log opnieuw in.</p>
|
||||
<a href='/auth/login.php' style='display: inline-block; margin-top: 20px; padding: 10px 20px; border-radius: 8px; background: #4f46e5; color: white; text-decoration: none; font-size: 13px; font-weight: 600;'>Naar Inloggen</a>
|
||||
</div>
|
||||
</div>";
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Foutrapportage: in live verbergen we fouten voor bezoekers
|
||||
if (APP_ENV !== 'live') {
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
} else {
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('display_startup_errors', '0');
|
||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
<?php
|
||||
/**
|
||||
* MariaDB PDO Databaseverbinding & Automatische Tabellencontrole
|
||||
* MariaDB PDO Databaseverbinding & Automatische Migratie
|
||||
*/
|
||||
|
||||
// Blokkeer directe HTTP toegang (Pen Test Beveiliging)
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === 'database.php') {
|
||||
http_response_code(403);
|
||||
exit('Toegang geweigerd.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
function get_db_connection(): PDO {
|
||||
@@ -21,18 +15,17 @@ function get_db_connection(): PDO {
|
||||
// 1. Controleer of de PDO MySQL/MariaDB driver aanwezig is in PHP
|
||||
$availableDrivers = PDO::getAvailableDrivers();
|
||||
if (!in_array('mysql', $availableDrivers)) {
|
||||
error_log("PDO MySQL driver (pdo_mysql) ontbreekt in PHP.");
|
||||
$driversList = !empty($availableDrivers) ? implode(', ', $availableDrivers) : 'geen';
|
||||
die("
|
||||
<div style='font-family: sans-serif; background: #0c0f17; color: #f1f5f9; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px;'>
|
||||
<div style='max-width: 580px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px;'>
|
||||
<h2 style='color: #ef4444; margin-top: 0;'>⚠️ Database Driver Ontbreekt</h2>
|
||||
<div style='font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; background: #090d16; color: #f1f5f9; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px;'>
|
||||
<div style='max-width: 580px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.5);'>
|
||||
<h2 style='color: #ef4444; margin-top: 0;'>⚠️ PHP MySQL Driver Ontbreekt (pdo_mysql)</h2>
|
||||
<p style='color: #94a3b8; font-size: 14px;'>De extensie <strong>pdo_mysql</strong> is niet actief in deze PHP-omgeving.</p>
|
||||
</div>
|
||||
</div>
|
||||
");
|
||||
}
|
||||
|
||||
$tzOffset = date('P'); // Dynamische offset conform Europe/Amsterdam (+02:00 in zomertijd, +01:00 in wintertijd)
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
@@ -42,9 +35,6 @@ function get_db_connection(): PDO {
|
||||
if (defined('PDO::MYSQL_ATTR_MULTI_STATEMENTS')) {
|
||||
$options[PDO::MYSQL_ATTR_MULTI_STATEMENTS] = true;
|
||||
}
|
||||
if (defined('PDO::MYSQL_ATTR_INIT_COMMAND')) {
|
||||
$options[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET time_zone = '{$tzOffset}'";
|
||||
}
|
||||
|
||||
$dsnWithDb = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
@@ -55,18 +45,19 @@ function get_db_connection(): PDO {
|
||||
);
|
||||
|
||||
// 2. CHECK: Bestaat de database al?
|
||||
// We proberen eerst direct verbinding te maken met de database.
|
||||
// Als de database al bestaat, lukt dit direct zonder database-aanmaakrechten te vereisen!
|
||||
try {
|
||||
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
|
||||
// Garandeer dat MariaDB sessietijd 1-op-1 synchroon loopt met PHP tijd (Europe/Amsterdam)
|
||||
$pdo->exec("SET time_zone = '{$tzOffset}'");
|
||||
|
||||
// De database bestaat al! Controleer enkel of de tabellen al aanwezig zijn
|
||||
ensure_tables_exist($pdo);
|
||||
return $pdo;
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("MariaDB Verbindingsfout [{$e->getCode()}]: {$e->getMessage()}");
|
||||
|
||||
// Foutcode 1049 betekent: Database 'DB_NAME' bestaat nog NIET op de MariaDB server
|
||||
if ($e->getCode() == 1049 || str_contains($e->getMessage(), 'Unknown database')) {
|
||||
// Probeer de database eenmalig aan te maken indien de gebruiker CREATE rechten heeft
|
||||
try {
|
||||
$serverDsn = sprintf('mysql:host=%s;port=%s;charset=%s', DB_HOST, DB_PORT, DB_CHARSET);
|
||||
$serverPdo = new PDO($serverDsn, DB_USER, DB_PASS, $options);
|
||||
@@ -75,32 +66,34 @@ function get_db_connection(): PDO {
|
||||
DB_NAME
|
||||
));
|
||||
|
||||
// Nu opnieuw verbinden met de nieuw aangemaakte database
|
||||
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
|
||||
$pdo->exec("SET time_zone = '{$tzOffset}'");
|
||||
ensure_tables_exist($pdo);
|
||||
return $pdo;
|
||||
|
||||
} catch (PDOException $createEx) {
|
||||
error_log("Kan database " . DB_NAME . " niet automatisch aanmaken: " . $createEx->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Pen Test beveiliging: in live omgeving geen interne IP's of database-details tonen!
|
||||
if (defined('APP_ENV') && APP_ENV === 'live') {
|
||||
die("
|
||||
<div style='font-family: sans-serif; background: #0c0f17; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='max-width: 480px; background: #111827; border: 1px solid #334155; border-radius: 16px; padding: 30px; text-align: center;'>
|
||||
<div style='width: 48px; height: 48px; margin: 0 auto 16px; border-radius: 12px; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); display: flex; align-items: center; justify-content: center; color: #f87171; font-weight: bold;'>⚠️</div>
|
||||
<h2 style='color: #f87171; margin-top: 0; font-size: 18px;'>Database Tijdelijk Niet Bereikbaar</h2>
|
||||
<p style='color: #94a3b8; font-size: 13px; line-height: 1.6;'>
|
||||
Er kon geen veilige verbinding tot stand worden gebracht met de database. Probeer het over enkele ogenblikken opnieuw.
|
||||
<div style='font-family: sans-serif; background: #090d16; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='max-width: 500px; background: #111827; border: 1px solid #f59e0b; border-radius: 16px; padding: 30px; text-align: left;'>
|
||||
<h2 style='color: #f59e0b; margin-top: 0;'>⚠️ Database '" . htmlspecialchars(DB_NAME) . "' bestaat nog niet</h2>
|
||||
<p style='color: #94a3b8; font-size: 14px; line-height: 1.6;'>
|
||||
De database <strong>" . htmlspecialchars(DB_NAME) . "</strong> bestaat nog niet op MariaDB server <strong>" . htmlspecialchars(DB_HOST) . "</strong> en de gebruiker <em>" . htmlspecialchars(DB_USER) . "</em> heeft geen rechten om automatisch databases aan te maken.
|
||||
</p>
|
||||
<p style='color: #cbd5e1; font-size: 13px;'>
|
||||
Maak de database <code>" . htmlspecialchars(DB_NAME) . "</code> eenmalig aan via phpMyAdmin, HeidiSQL of de MariaDB CLI:
|
||||
</p>
|
||||
<code style='display: block; background: #000; color: #4ade80; padding: 10px; border-radius: 8px; font-size: 12px; margin: 10px 0;'>
|
||||
CREATE DATABASE IF NOT EXISTS `" . htmlspecialchars(DB_NAME) . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
");
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
// Andere verbindingsfout (bijv. verkeerd wachtwoord, poort of host onbereikbaar)
|
||||
die("
|
||||
<div style='font-family: sans-serif; background: #0c0f17; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='font-family: sans-serif; background: #090d16; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
|
||||
<div style='max-width: 520px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px; text-align: left;'>
|
||||
<h2 style='color: #ef4444; margin-top: 0;'>MariaDB Verbindingsfout (" . htmlspecialchars((string)$e->getCode()) . ")</h2>
|
||||
<p style='color: #cbd5e1; font-size: 14px;'>" . htmlspecialchars($e->getMessage()) . "</p>
|
||||
@@ -114,202 +107,36 @@ function get_db_connection(): PDO {
|
||||
</div>
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zorgt ervoor dat ALLE benodigde tabellen en kolommen uit schema.sql aanwezig zijn in MariaDB
|
||||
* Voert elke tabel individueel uit zodat ontbrekende tabellen direct aangemaakt worden.
|
||||
* Zorgt ervoor dat alle benodigde tabellen uit schema.sql aanwezig zijn in MariaDB
|
||||
* Voert niets uit als de tabellen al bestaan!
|
||||
*/
|
||||
function ensure_tables_exist(PDO $pdo): void {
|
||||
static $tablesChecked = false;
|
||||
if ($tablesChecked) return;
|
||||
$tablesChecked = true;
|
||||
|
||||
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
|
||||
$lockFile = sys_get_temp_dir() . '/fociz_schema_' . md5(__DIR__) . '.lock';
|
||||
|
||||
if (file_exists($lockFile) && file_exists($schemaFile) && filemtime($lockFile) >= filemtime($schemaFile)) {
|
||||
try {
|
||||
// Controleer of de hoofdtabel 'users' al bestaat
|
||||
$result = $pdo->query("SHOW TABLES LIKE 'users'");
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
// Tabellen bestaan al! Geen actie nodig.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Tabellen ontbreken nog: voer schema.sql uit statement voor statement
|
||||
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
|
||||
if (file_exists($schemaFile)) {
|
||||
$sql = file_get_contents($schemaFile);
|
||||
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||
foreach ($statements as $stmt) {
|
||||
// Verwijder eventuele SQL commentaren aan het begin
|
||||
$stmtClean = preg_replace('/^--.*$/m', '', $stmt);
|
||||
$stmtClean = trim($stmtClean);
|
||||
if (!empty($stmtClean)) {
|
||||
try {
|
||||
$pdo->exec($stmtClean);
|
||||
} catch (Throwable $t) {
|
||||
// Stil negeren indien tabel of relatie al bestaat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zorg ervoor dat alle kolommen en upgrades aanwezig zijn
|
||||
$colUpgrades = [
|
||||
"ALTER TABLE tasks MODIFY external_id VARCHAR(255) NULL",
|
||||
"ALTER TABLE tasks ADD COLUMN external_url TEXT NULL AFTER external_id",
|
||||
"ALTER TABLE users ADD COLUMN remember_token VARCHAR(64) NULL AFTER backup_codes",
|
||||
"ALTER TABLE users ADD COLUMN remember_token_expires_at DATETIME NULL AFTER remember_token",
|
||||
"ALTER TABLE users ADD COLUMN notification_settings TEXT NULL AFTER remember_token_expires_at",
|
||||
"ALTER TABLE users ADD COLUMN theme VARCHAR(20) NOT NULL DEFAULT 'dark' AFTER notification_settings",
|
||||
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255) NULL AFTER theme",
|
||||
"ALTER TABLE users MODIFY COLUMN avatar_url MEDIUMTEXT NULL",
|
||||
"ALTER TABLE users ADD COLUMN backup_codes TEXT NULL AFTER two_factor_enabled",
|
||||
"ALTER TABLE users ADD COLUMN default_jira_filter_code VARCHAR(50) NULL AFTER avatar_url",
|
||||
"ALTER TABLE users ADD COLUMN fociz_widget_layout TEXT NULL AFTER default_jira_filter_code",
|
||||
"ALTER TABLE users ADD COLUMN last_mail_check_at DATETIME NULL AFTER fociz_widget_layout",
|
||||
"ALTER TABLE users ADD COLUMN last_jira_check_at DATETIME NULL AFTER last_mail_check_at",
|
||||
"ALTER TABLE board_card_schedules ADD UNIQUE KEY uk_sched_card (card_id)",
|
||||
"CREATE TABLE IF NOT EXISTS `password_resets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `email` VARCHAR(191) NOT NULL, `token` VARCHAR(64) NOT NULL UNIQUE, `expires_at` DATETIME NOT NULL, `used_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`email`), INDEX (`token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS `jira_queue_filters` (`id` INT AUTO_INCREMENT PRIMARY KEY, `filter_code` VARCHAR(50) NOT NULL UNIQUE, `filter_name` VARCHAR(120) NOT NULL, `project_key` VARCHAR(50) NULL, `jql_clause` TEXT NOT NULL, `description` VARCHAR(255) NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `sort_order` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// Jira: Meerdere wachtrijfilters per teamlid
|
||||
"ALTER TABLE users ADD COLUMN default_jira_filter_codes TEXT NULL AFTER default_jira_filter_code",
|
||||
// V2.0: Subtaken & Checklists
|
||||
"CREATE TABLE IF NOT EXISTS `task_subtasks` (`id` INT AUTO_INCREMENT PRIMARY KEY, `task_id` INT NOT NULL, `user_id` INT NOT NULL, `title` VARCHAR(255) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`task_id`, `position`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// Beveiliging: IP tracking & locatie-verificatie
|
||||
"ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45) NULL AFTER last_jira_check_at",
|
||||
"ALTER TABLE users ADD COLUMN last_login_at DATETIME NULL AFTER last_login_ip",
|
||||
"CREATE TABLE IF NOT EXISTS `user_known_ips` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `is_verified` TINYINT(1) NOT NULL DEFAULT 1, `verify_token` VARCHAR(64) NULL, `verify_token_expires_at` DATETIME NULL, `first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`), INDEX (`verify_token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// SOC 2 & ISO 27001: Onwijzigbare Audit Trail
|
||||
"CREATE TABLE IF NOT EXISTS `audit_logs` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NULL, `user_email` VARCHAR(191) NULL, `action` VARCHAR(64) NOT NULL, `resource_type` VARCHAR(64) NULL, `resource_id` INT NULL, `details` TEXT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`user_id`), INDEX (`action`), INDEX (`created_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// Agenda & Verlof: Hele of meerdere dagen blokkeren (Vakantie, Vrije dag, Ziekte)
|
||||
"CREATE TABLE IF NOT EXISTS `calendar_blocks` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `start_date` DATE NOT NULL, `end_date` DATE NOT NULL, `title` VARCHAR(255) NOT NULL, `block_type` VARCHAR(50) NOT NULL DEFAULT 'VACATION', `notes` TEXT NULL, `outlook_event_id` VARCHAR(255) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `start_date`, `end_date`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// Centrale Systeeminstellingen (AI, Werkdagen, Sessiebeveiliging)
|
||||
"CREATE TABLE IF NOT EXISTS `system_settings` (`setting_key` VARCHAR(64) PRIMARY KEY, `setting_value` LONGTEXT NULL, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"ALTER TABLE `system_settings` MODIFY COLUMN `setting_value` LONGTEXT NULL",
|
||||
// Brainstorm kaarten toewijzen aan collega's en agenda-inplanning
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN assignee_id INT NULL AFTER color",
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_date DATE NULL AFTER assignee_id",
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_start_time VARCHAR(10) NULL AFTER scheduled_date",
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
|
||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
|
||||
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
|
||||
// Dashboard Snelle Bullet Notities & Todo Lijst
|
||||
"CREATE TABLE IF NOT EXISTS `dashboard_bullets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `plan_date` DATE NOT NULL, `content` VARCHAR(500) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `scheduled_task_id` INT NULL, `scheduled_date` DATE NULL, `scheduled_start_time` VARCHAR(10) NULL, `scheduled_end_time` VARCHAR(10) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `plan_date`), INDEX (`scheduled_task_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
foreach ($colUpgrades as $sql) {
|
||||
try {
|
||||
$pdo->exec($sql);
|
||||
} catch (Throwable $t) {
|
||||
// Stil negeren indien kolom al bestaat
|
||||
}
|
||||
}
|
||||
|
||||
// Zorg dat de standaard Jira wachtrijfilters (SD, IN, AAN, GEN, DX) aanwezig zijn
|
||||
try {
|
||||
$existingCodes = $pdo->query("SELECT filter_code FROM jira_queue_filters")->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
$defaultFilters = [
|
||||
[
|
||||
'code' => 'SD',
|
||||
'name' => 'SD — Servicedesk',
|
||||
'project' => 'SD',
|
||||
'jql' => 'project = "SD"',
|
||||
'desc' => 'Servicedesk supportvragen en operationele meldingen.',
|
||||
'order' => 1
|
||||
],
|
||||
[
|
||||
'code' => 'IN',
|
||||
'name' => 'IN — Interfaces',
|
||||
'project' => 'IN',
|
||||
'jql' => 'project = "IN"',
|
||||
'desc' => 'Interface-koppelingen, API-endpoints en data-uitwisselingen.',
|
||||
'order' => 2
|
||||
],
|
||||
[
|
||||
'code' => 'AAN',
|
||||
'name' => 'AAN — Aanvragen',
|
||||
'project' => 'AAN',
|
||||
'jql' => 'project = "AAN"',
|
||||
'desc' => 'Klant- en gebruikersaanvragen en wijzigingsverzoeken.',
|
||||
'order' => 3
|
||||
],
|
||||
[
|
||||
'code' => 'GEN',
|
||||
'name' => 'GEN — General',
|
||||
'project' => 'GEN',
|
||||
'jql' => 'project = "GEN"',
|
||||
'desc' => 'Algemene taken, organisatie en platformprojecten.',
|
||||
'order' => 4
|
||||
],
|
||||
[
|
||||
'code' => 'DX',
|
||||
'name' => 'DX — Koppelingen & SSO',
|
||||
'project' => 'SD',
|
||||
'jql' => '"Onderwerp logging[dropdown]" in (Koppelingen, SSO-koppeling, Rest-endpoints, Datadump, UWV-koppeling, Migratie)',
|
||||
'desc' => 'Servicedesk tickets specifiek voor koppelingen en migraties.',
|
||||
'order' => 5
|
||||
]
|
||||
];
|
||||
$insertFilterStmt = $pdo->prepare("
|
||||
INSERT INTO jira_queue_filters (filter_code, filter_name, project_key, jql_clause, description, is_active, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?)
|
||||
");
|
||||
foreach ($defaultFilters as $df) {
|
||||
if (!in_array($df['code'], $existingCodes)) {
|
||||
$insertFilterStmt->execute([
|
||||
$df['code'], $df['name'], $df['project'], $df['jql'], $df['desc'], $df['order']
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien tabel nog in opbouw is
|
||||
}
|
||||
|
||||
// Zorg dat er ten minste één standaard bord met standaard lanes aanwezig is
|
||||
try {
|
||||
$boardCount = (int)$pdo->query("SELECT COUNT(*) FROM boards")->fetchColumn();
|
||||
if ($boardCount === 0) {
|
||||
$firstAdminId = (int)$pdo->query("SELECT id FROM users ORDER BY (role = 'ADMIN') DESC, id ASC LIMIT 1")->fetchColumn();
|
||||
if ($firstAdminId > 0) {
|
||||
$pdo->prepare("INSERT INTO boards (title, description, created_by_id) VALUES (?, ?, ?)")
|
||||
->execute(['Hoofdbord', 'Standaard team- en takenbord voor opdrachten', $firstAdminId]);
|
||||
$boardId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Maak beheerder lid van het bord
|
||||
$pdo->prepare("INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'ADMIN')")
|
||||
->execute([$boardId, $firstAdminId]);
|
||||
|
||||
// Maak de 4 standaard lanes aan
|
||||
$defaultLanes = [
|
||||
['title' => 'Open', 'color' => 'slate', 'pos' => 0],
|
||||
['title' => 'In uitvoering', 'color' => 'amber', 'pos' => 1],
|
||||
['title' => 'Testen', 'color' => 'purple', 'pos' => 2],
|
||||
['title' => 'Klaar', 'color' => 'emerald', 'pos' => 3],
|
||||
];
|
||||
$laneStmt = $pdo->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)");
|
||||
foreach ($defaultLanes as $l) {
|
||||
$laneStmt->execute([$boardId, $l['title'], $l['color'], $l['pos']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien borden tabel nog niet bestaat
|
||||
}
|
||||
|
||||
// Zorg dat eerdere audit logs die in UTC (+00:00) zijn weggeschreven worden gesynchroniseerd naar Europe/Amsterdam (+2 uur)
|
||||
try {
|
||||
$stmtCheck = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'audit_logs_tz_fixed' LIMIT 1");
|
||||
$fixed = $stmtCheck ? $stmtCheck->fetchColumn() : null;
|
||||
if (!$fixed) {
|
||||
// Pas bestaande audit logs eenmalig aan zodat de weergave klopt met de lokale tijd
|
||||
$pdo->exec("UPDATE audit_logs SET created_at = DATE_ADD(created_at, INTERVAL 2 HOUR)");
|
||||
$pdo->prepare("
|
||||
INSERT INTO system_settings (setting_key, setting_value, updated_at)
|
||||
VALUES ('audit_logs_tz_fixed', '1', NOW())
|
||||
ON DUPLICATE KEY UPDATE setting_value = '1', updated_at = NOW()
|
||||
")->execute();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien system_settings tabel nog niet bestaat
|
||||
}
|
||||
@touch($lockFile);
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij controleren tabellen: " . $e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
error_log("Fout bij aanmaken tabellen: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<?php
|
||||
http_response_code(403);
|
||||
exit('Toegang geweigerd.');
|
||||
@@ -10,16 +10,7 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
`two_factor_secret` VARCHAR(120) NULL,
|
||||
`two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`backup_codes` TEXT NULL,
|
||||
`remember_token` VARCHAR(64) NULL,
|
||||
`remember_token_expires_at` DATETIME NULL,
|
||||
`notification_settings` TEXT NULL,
|
||||
`avatar_url` MEDIUMTEXT NULL,
|
||||
`default_jira_filter_code` VARCHAR(50) NULL,
|
||||
`fociz_widget_layout` TEXT NULL,
|
||||
`last_mail_check_at` DATETIME NULL,
|
||||
`last_jira_check_at` DATETIME NULL,
|
||||
`last_login_ip` VARCHAR(45) NULL,
|
||||
`last_login_at` DATETIME NULL,
|
||||
`avatar_url` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -37,17 +28,6 @@ CREATE TABLE IF NOT EXISTS `invites` (
|
||||
CONSTRAINT `fk_invites_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `password_resets` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`email` VARCHAR(191) NOT NULL,
|
||||
`token` VARCHAR(64) NOT NULL UNIQUE,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`used_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (`email`),
|
||||
INDEX (`token`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `daily_plans` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
@@ -74,7 +54,7 @@ CREATE TABLE IF NOT EXISTS `tasks` (
|
||||
`duration_minutes` INT NULL DEFAULT 30,
|
||||
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`source` VARCHAR(50) NOT NULL DEFAULT 'FOCIZ',
|
||||
`external_id` VARCHAR(255) NULL,
|
||||
`external_id` VARCHAR(100) NULL,
|
||||
`external_url` TEXT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -101,18 +81,12 @@ CREATE TABLE IF NOT EXISTS `brainstorm_cards` (
|
||||
`content` TEXT NOT NULL,
|
||||
`category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën',
|
||||
`color` VARCHAR(20) NOT NULL DEFAULT 'amber',
|
||||
`assignee_id` INT NULL,
|
||||
`scheduled_date` DATE NULL,
|
||||
`scheduled_start_time` VARCHAR(10) NULL,
|
||||
`scheduled_end_time` VARCHAR(10) NULL,
|
||||
`scheduled_task_id` INT NULL,
|
||||
`votes` INT NOT NULL DEFAULT 0,
|
||||
`author_name` VARCHAR(120) NULL,
|
||||
`converted_to_roadmap_item_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`session_id`),
|
||||
INDEX (`assignee_id`),
|
||||
CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -151,187 +125,4 @@ CREATE TABLE IF NOT EXISTS `integrations` (
|
||||
UNIQUE KEY `uk_user_provider` (`user_id`, `provider`),
|
||||
CONSTRAINT `fk_integrations_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
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;
|
||||
|
||||
-- Fociz Kanban / Trello Borden, Lanes, Kaarten & Tijdspadkoppelingen
|
||||
CREATE TABLE IF NOT EXISTS `boards` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`created_by_id` INT NOT NULL,
|
||||
`is_archived` TINYINT(1) 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 (`created_by_id`),
|
||||
CONSTRAINT `fk_boards_creator` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `board_members` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`board_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`role` ENUM('ADMIN', 'MEMBER', 'VIEWER') NOT NULL DEFAULT 'MEMBER',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_board_user` (`board_id`, `user_id`),
|
||||
CONSTRAINT `fk_bmembers_board` FOREIGN KEY (`board_id`) REFERENCES `boards` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_bmembers_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `board_lanes` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`board_id` INT NOT NULL,
|
||||
`title` VARCHAR(100) NOT NULL,
|
||||
`color` VARCHAR(30) NOT NULL DEFAULT 'slate',
|
||||
`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 (`board_id`, `position`),
|
||||
CONSTRAINT `fk_blanes_board` FOREIGN KEY (`board_id`) REFERENCES `boards` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `board_cards` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`board_id` INT NOT NULL,
|
||||
`lane_id` INT NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`assignee_id` INT NULL,
|
||||
`priority` ENUM('P1', 'P2', 'P3') NOT NULL DEFAULT 'P2',
|
||||
`color` VARCHAR(30) NOT NULL DEFAULT 'yellow',
|
||||
`position` INT NOT NULL DEFAULT 0,
|
||||
`created_by_id` INT NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`board_id`, `lane_id`, `position`),
|
||||
INDEX (`assignee_id`),
|
||||
CONSTRAINT `fk_bcards_board` FOREIGN KEY (`board_id`) REFERENCES `boards` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_bcards_lane` FOREIGN KEY (`lane_id`) REFERENCES `board_lanes` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_bcards_assignee` FOREIGN KEY (`assignee_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_bcards_creator` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `board_card_schedules` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`card_id` INT NOT NULL,
|
||||
`schedule_type` ENUM('SINGLE', 'DAILY_RANGE') NOT NULL DEFAULT 'SINGLE',
|
||||
`start_date` DATE NOT NULL,
|
||||
`end_date` DATE NOT NULL,
|
||||
`start_time` VARCHAR(10) NOT NULL DEFAULT '14:00',
|
||||
`end_time` VARCHAR(10) NOT NULL DEFAULT '15:00',
|
||||
`days_of_week` VARCHAR(50) NULL DEFAULT '1,2,3,4,5',
|
||||
`outlook_event_id` VARCHAR(255) NULL,
|
||||
`outlook_synced_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_sched_card` (`card_id`),
|
||||
CONSTRAINT `fk_bsched_card` FOREIGN KEY (`card_id`) REFERENCES `board_cards` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `jira_queue_filters` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`filter_code` VARCHAR(50) NOT NULL UNIQUE,
|
||||
`filter_name` VARCHAR(120) NOT NULL,
|
||||
`project_key` VARCHAR(50) NULL,
|
||||
`jql_clause` TEXT NOT NULL,
|
||||
`description` VARCHAR(255) NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_cache` (
|
||||
`cache_key` VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
`cache_value` MEDIUMTEXT NOT NULL,
|
||||
`expires_at` INT UNSIGNED NOT NULL,
|
||||
INDEX `idx_app_cache_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz V2.0: Subtaken & Checklists per taak
|
||||
CREATE TABLE IF NOT EXISTS `task_subtasks` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`task_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`position` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`task_id`, `position`),
|
||||
CONSTRAINT `fk_subtasks_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_subtasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz Beveiliging: Bekende IP-adressen en verificatie per gebruiker
|
||||
CREATE TABLE IF NOT EXISTS `user_known_ips` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`is_verified` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`verify_token` VARCHAR(64) NULL,
|
||||
`verify_token_expires_at` DATETIME NULL,
|
||||
`first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`),
|
||||
INDEX (`verify_token`),
|
||||
CONSTRAINT `fk_known_ips_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz SOC 2 & ISO 27001: Onwijzigbare Audit Trail (Beveiligings- en beheeracties)
|
||||
CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NULL,
|
||||
`user_email` VARCHAR(191) NULL,
|
||||
`action` VARCHAR(64) NOT NULL,
|
||||
`resource_type` VARCHAR(64) NULL,
|
||||
`resource_id` INT NULL,
|
||||
`details` TEXT NULL,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (`user_id`),
|
||||
INDEX (`action`),
|
||||
INDEX (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz SOC 2 CC6.1 & ISO 27001 A.9.4.2: Actieve Sessietracking & Remote Revocation
|
||||
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
`session_id_hash` VARCHAR(64) NOT NULL UNIQUE,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`device_name` VARCHAR(100) NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`last_activity_at` DATETIME NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (`user_id`),
|
||||
INDEX (`session_id_hash`),
|
||||
INDEX (`expires_at`),
|
||||
INDEX (`is_active`),
|
||||
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz Agenda & Verlof: Hele of meerdere dagen blokkeren (Vakantie, Vrije dag, Ziekte)
|
||||
CREATE TABLE IF NOT EXISTS `calendar_blocks` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
`start_date` DATE NOT NULL,
|
||||
`end_date` DATE NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`block_type` VARCHAR(50) NOT NULL DEFAULT 'VACATION',
|
||||
`notes` TEXT NULL,
|
||||
`outlook_event_id` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`user_id`, `start_date`, `end_date`),
|
||||
CONSTRAINT `fk_calblocks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="focizGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="50%" stop-color="#4f46e5"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<filter id="focizShadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4f46e5" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Achtergrond met afgeronde hoeken (superellipse / squircl) -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#focizGrad)" filter="url(#focizShadow)"/>
|
||||
<!-- Subtiele binnenrand highlight -->
|
||||
<rect x="3" y="3" width="58" height="58" rx="15" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- Karakteristieke Fociz 'F' -->
|
||||
<path d="M22 17 H43 C44.6569 17 46 18.3431 46 20 C46 21.6569 44.6569 23 43 23 H28 V29 H39 C40.6569 29 42 30.3431 42 32 C42 33.6569 40.6569 35 39 35 H28 V44 C28 45.6569 26.6569 47 25 47 C23.3431 47 22 45.6569 22 44 V17 Z" fill="#ffffff"/>
|
||||
<!-- Subtiele focus stip / accent -->
|
||||
<circle cx="43" cy="43" r="3.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="focizGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="50%" stop-color="#4f46e5"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
<filter id="focizShadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4f46e5" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Achtergrond met afgeronde hoeken (superellipse / squircl) -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#focizGrad)" filter="url(#focizShadow)"/>
|
||||
<!-- Subtiele binnenrand highlight -->
|
||||
<rect x="3" y="3" width="58" height="58" rx="15" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- Karakteristieke Fociz 'F' -->
|
||||
<path d="M22 17 H43 C44.6569 17 46 18.3431 46 20 C46 21.6569 44.6569 23 43 23 H28 V29 H39 C40.6569 29 42 30.3431 42 32 C42 33.6569 40.6569 35 39 35 H28 V44 C28 45.6569 26.6569 47 25 47 C23.3431 47 22 45.6569 22 44 V17 Z" fill="#ffffff"/>
|
||||
<!-- Subtiele focus stip / accent -->
|
||||
<circle cx="43" cy="43" r="3.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,9 +0,0 @@
|
||||
# Beveiliging van de interne includes map (Pen Test Beveiliging)
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
||||
@@ -1,983 +0,0 @@
|
||||
<?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';
|
||||
|
||||
/**
|
||||
* STRICT ZERO-DATA LEAKAGE PRIVACY FIREWALL
|
||||
* Garandeert dat NOOIT data uit Jira, Mail (Outlook) of Microsoft Teams
|
||||
* doorgestuurd kan worden naar een externe AI provider (Gemini / OpenAI).
|
||||
*
|
||||
* @param array $messages Referentie naar de messages array die verzonden wordt
|
||||
* @return array Aantal en type geactiveerde firewall filters
|
||||
*/
|
||||
function fociz_ai_enforce_privacy_firewall(array &$messages): array {
|
||||
$detections = [
|
||||
'jira_tickets' => 0,
|
||||
'jira_urls' => 0,
|
||||
'teams_links' => 0,
|
||||
'email_addrs' => 0,
|
||||
'mail_headers' => 0,
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
// Jira ticket keys zoals PROJ-1234, ABC-99 (2 tot 10 hoofdletters gevolgd door streepje en cijfers)
|
||||
'jira_tickets' => '/\b[A-Z]{2,10}-\d+\b/',
|
||||
// Jira / Atlassian domeinen en URLs
|
||||
'jira_urls' => '/https?:\/\/[a-zA-Z0-9-]+\.atlassian\.net[^\s]*/i',
|
||||
// Teams URLs en links
|
||||
'teams_links' => '/https?:\/\/teams\.microsoft\.com[^\s]*/i',
|
||||
// E-mailadressen
|
||||
'email_addrs' => '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/',
|
||||
// E-mailheaders en Microsoft Outlook / Teams traces
|
||||
'mail_headers' => '/\b(Subject:|Onderwerp:|From:|Van:|To:|Aan:|CC:|BCC:|FW:|Fwd:|RE:)\s*[^\r\n]+/i',
|
||||
];
|
||||
|
||||
$replacements = [
|
||||
'jira_tickets' => '[TICKET_AFGESCHERMD]',
|
||||
'jira_urls' => '[JIRA_URL_AFGESCHERMD]',
|
||||
'teams_links' => '[TEAMS_LINK_AFGESCHERMD]',
|
||||
'email_addrs' => '[EMAIL_AFGESCHERMD]',
|
||||
'mail_headers' => '[MAIL_HEADER_AFGESCHERMD]',
|
||||
];
|
||||
|
||||
foreach ($messages as $idx => $msg) {
|
||||
if (!isset($msg['content']) || !is_string($msg['content'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = $msg['content'];
|
||||
foreach ($patterns as $key => $pattern) {
|
||||
$count = 0;
|
||||
$text = preg_replace($pattern, $replacements[$key], $text, -1, $count);
|
||||
if ($count > 0) {
|
||||
$detections[$key] += $count;
|
||||
}
|
||||
}
|
||||
$messages[$idx]['content'] = $text;
|
||||
}
|
||||
|
||||
$totalBlocked = array_sum($detections);
|
||||
if ($totalBlocked > 0) {
|
||||
error_log(sprintf(
|
||||
"[FOCIZ AI FIREWALL] Zero-Data Leakage actief: %d potentiële datalekken geblokkeerd en geanonimiseerd (%s)",
|
||||
$totalBlocked,
|
||||
json_encode($detections)
|
||||
));
|
||||
}
|
||||
|
||||
return $detections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voert een gestructureerde chat completion uit met Google Gemini Pro
|
||||
*
|
||||
* @param array $messages
|
||||
* @param array $options
|
||||
* @param array $aiSettings
|
||||
* @return array
|
||||
*/
|
||||
function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings): array {
|
||||
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
|
||||
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gemini-2.0-flash';
|
||||
$temperature = $options['temperature'] ?? 0.3;
|
||||
$maxTokens = max(512, (int)($options['max_tokens'] ?? 2048));
|
||||
$jsonMode = !empty($options['json_mode']);
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen Google Gemini API-sleutel ingevuld of geconfigureerd.',
|
||||
];
|
||||
}
|
||||
|
||||
// Google Generative Language API structuur opbouwen
|
||||
$systemInstructions = [];
|
||||
$contents = [];
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$role = $msg['role'] ?? 'user';
|
||||
$text = (string)($msg['content'] ?? '');
|
||||
|
||||
if ($role === 'system') {
|
||||
$systemInstructions[] = ['text' => $text];
|
||||
} else {
|
||||
$contents[] = [
|
||||
'role' => ($role === 'assistant') ? 'model' : 'user',
|
||||
'parts' => [['text' => $text]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($contents)) {
|
||||
$contents[] = [
|
||||
'role' => 'user',
|
||||
'parts' => [['text' => 'Hallo']],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'contents' => $contents,
|
||||
'generationConfig' => [
|
||||
'temperature' => (float)$temperature,
|
||||
'maxOutputTokens' => (int)$maxTokens,
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($systemInstructions)) {
|
||||
$payload['system_instruction'] = [
|
||||
'parts' => $systemInstructions,
|
||||
];
|
||||
}
|
||||
|
||||
if ($jsonMode) {
|
||||
$payload['generationConfig']['responseMimeType'] = 'application/json';
|
||||
}
|
||||
|
||||
$cleanModel = preg_replace('#^models/#', '', $model);
|
||||
|
||||
// Helper om tekst uit Gemini candidate te extraheren
|
||||
$extractCandidateText = function(?array $candidate): string {
|
||||
if (!$candidate || empty($candidate['content']['parts'])) {
|
||||
return '';
|
||||
}
|
||||
$txt = '';
|
||||
foreach ($candidate['content']['parts'] as $part) {
|
||||
if (!empty($part['text']) && empty($part['thought'])) {
|
||||
$txt .= $part['text'];
|
||||
}
|
||||
}
|
||||
if ($txt === '' && !empty($candidate['content']['parts'][0]['text'])) {
|
||||
$txt = $candidate['content']['parts'][0]['text'];
|
||||
}
|
||||
return trim($txt);
|
||||
};
|
||||
|
||||
// Normaliseer modelnaam en stel geldige kandidatenlijst op
|
||||
$candidateUrls = [];
|
||||
|
||||
// Helper om endpoints op te bouwen (prioriteer v1beta voor moderne Gemini modellen)
|
||||
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
|
||||
$m = preg_replace('#^models/#', '', trim($m));
|
||||
if ($m === '') return;
|
||||
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
|
||||
if (!in_array($v1beta, $candidateUrls, true)) {
|
||||
$candidateUrls[] = $v1beta;
|
||||
}
|
||||
};
|
||||
|
||||
// Voeg het geconfigureerde model toe als eerste kandidaat (mits niet fictief)
|
||||
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
|
||||
$addCandidatesFor($cleanModel);
|
||||
}
|
||||
|
||||
// Voeg altijd de snelle, hoog-beschikbare Google AI modellen toe als betrouwbare fallbacks
|
||||
$addCandidatesFor('gemini-2.0-flash');
|
||||
$addCandidatesFor('gemini-1.5-flash');
|
||||
$addCandidatesFor('gemini-2.5-flash');
|
||||
$addCandidatesFor('gemini-1.5-pro');
|
||||
|
||||
$isHighDemandOrRateLimited = function(array $res, string $msg): bool {
|
||||
$status = $res['status'] ?? 0;
|
||||
if ($status === 503 || $status === 429) {
|
||||
return true;
|
||||
}
|
||||
$lower = strtolower($msg);
|
||||
return (
|
||||
str_contains($lower, 'high demand') ||
|
||||
str_contains($lower, 'spikes in demand') ||
|
||||
str_contains($lower, 'temporarily unavailable') ||
|
||||
str_contains($lower, 'resource has been exhausted') ||
|
||||
str_contains($lower, 'rate limit') ||
|
||||
str_contains($lower, 'overloaded') ||
|
||||
str_contains($lower, 'too many requests')
|
||||
);
|
||||
};
|
||||
|
||||
$response = null;
|
||||
$lastError = null;
|
||||
$successfulModel = $cleanModel;
|
||||
$finalText = '';
|
||||
|
||||
for ($i = 0; $i < count($candidateUrls); $i++) {
|
||||
$baseUrl = $candidateUrls[$i];
|
||||
$endpoint = $baseUrl . '?key=' . urlencode($apiKey);
|
||||
|
||||
// Doe max 2 pogingen met korte micro-backoff bij een tijdelijke piek (transient spike)
|
||||
for ($attempt = 1; $attempt <= 2; $attempt++) {
|
||||
$res = fociz_http_request($endpoint, [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'x-goog-api-key' => $apiKey,
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 25,
|
||||
]);
|
||||
|
||||
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
|
||||
$extracted = $extractCandidateText($res['json']['candidates'][0]);
|
||||
// Als er tekst is, of finishReason is geldig (bijv. STOP of MAX_TOKENS)
|
||||
if ($extracted !== '' || !empty($res['json']['candidates'][0]['finishReason'])) {
|
||||
$response = $res;
|
||||
$finalText = ($extracted !== '') ? $extracted : 'OK';
|
||||
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
|
||||
$successfulModel = $mName[1];
|
||||
}
|
||||
break 2; // Volledig succes, beëindig beide loops
|
||||
}
|
||||
}
|
||||
|
||||
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
|
||||
$lastError = $msg;
|
||||
|
||||
// Als dit een tijdelijke piek is en we nog een poging over hebben, wacht kort en probeer nogmaals
|
||||
if ($attempt < 2 && $isHighDemandOrRateLimited($res, $msg)) {
|
||||
usleep(800000); // 800ms
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
|
||||
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
|
||||
$recModel = $recMatch[1];
|
||||
$recUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$recModel}:generateContent";
|
||||
if (!in_array($recUrl, $candidateUrls, true)) {
|
||||
$candidateUrls[] = $recUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Model niet gevonden of deprecated -> probeer de volgende kandidaat
|
||||
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. HIGH DEMAND / 503 / 429 OVERBELASTING: NIET STOPPEN!
|
||||
// Schakel direct door naar de volgende fallback kandidaat (bijv. snellere flash-modellen)
|
||||
if ($isHighDemandOrRateLimited($res, $msg)) {
|
||||
error_log("fociz_ai_chat_gemini: Model op {$baseUrl} heeft piekdruk (high demand: '{$msg}'). Schakelt automatisch door naar alternatief model...");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alleen bij expliciete autorisatiefouten (ongeldige API key 400/401/403) stoppen
|
||||
if (in_array($res['status'], [400, 401, 403], true)) {
|
||||
$response = $res;
|
||||
break;
|
||||
}
|
||||
|
||||
// Voor alle andere fouten (serverfout 500, timeout, netwerkdip): probeer volgende kandidaat
|
||||
continue;
|
||||
}
|
||||
|
||||
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
|
||||
if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) {
|
||||
$listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey);
|
||||
$listRes = fociz_http_request($listUrl, [
|
||||
'method' => 'GET',
|
||||
'headers' => ['x-goog-api-key' => $apiKey],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
if ($listRes['status'] === 200 && !empty($listRes['json']['models'])) {
|
||||
$availModels = [];
|
||||
foreach ($listRes['json']['models'] as $mItem) {
|
||||
$mMethods = $mItem['supportedGenerationMethods'] ?? [];
|
||||
if (in_array('generateContent', $mMethods, true)) {
|
||||
$mClean = preg_replace('#^models/#', '', $mItem['name'] ?? '');
|
||||
if ($mClean) $availModels[] = $mClean;
|
||||
}
|
||||
}
|
||||
|
||||
// Zoek een passend gemini model
|
||||
$fallbackModel = null;
|
||||
foreach ($availModels as $am) {
|
||||
if (str_contains($am, 'flash')) {
|
||||
$fallbackModel = $am;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$fallbackModel) {
|
||||
foreach ($availModels as $am) {
|
||||
if (str_contains($am, 'gemini')) {
|
||||
$fallbackModel = $am;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($fallbackModel) {
|
||||
$dynEndpoint = "https://generativelanguage.googleapis.com/v1beta/models/{$fallbackModel}:generateContent?key=" . urlencode($apiKey);
|
||||
$dynRes = fociz_http_request($dynEndpoint, [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'x-goog-api-key' => $apiKey,
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 20,
|
||||
]);
|
||||
|
||||
if ($dynRes['status'] === 200 && !empty($dynRes['json']['candidates'][0])) {
|
||||
$extracted = $extractCandidateText($dynRes['json']['candidates'][0]);
|
||||
if ($extracted !== '' || !empty($dynRes['json']['candidates'][0]['finishReason'])) {
|
||||
$response = $dynRes;
|
||||
$finalText = ($extracted !== '') ? $extracted : 'OK';
|
||||
$successfulModel = $fallbackModel;
|
||||
}
|
||||
} else {
|
||||
$response = $dynRes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$response || $response['status'] !== 200 || $finalText === '') {
|
||||
$errMsg = null;
|
||||
if (!empty($response['json']['error']['message'])) {
|
||||
$errMsg = $response['json']['error']['message'];
|
||||
} elseif (!empty($response['error'])) {
|
||||
$errMsg = $response['error'];
|
||||
} elseif (!empty($response['body'])) {
|
||||
$errMsg = "Gemini fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
|
||||
} else {
|
||||
$errMsg = $lastError ?: "Gemini API aanroep mislukt";
|
||||
}
|
||||
error_log("fociz_ai_chat_gemini fout: {$errMsg}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$rawContent = $finalText;
|
||||
|
||||
// Indien Gemini het als markdown code block wrapped heeft (bv. ```json ... ```), schoon dit op
|
||||
if (preg_match('/^```(?:json)?\s*(.*?)\s*```$/s', $rawContent, $m)) {
|
||||
$cleanContent = trim($m[1]);
|
||||
} else {
|
||||
$cleanContent = $rawContent;
|
||||
}
|
||||
|
||||
$parsedJson = null;
|
||||
if ($jsonMode) {
|
||||
$parsedJson = json_decode($cleanContent, true);
|
||||
if ($parsedJson === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
// Poging 2: json decode met raw
|
||||
$parsedJson = json_decode($rawContent, true);
|
||||
}
|
||||
// Poging 3: extraheer JSON substring {...} of [...] indien AI conversatietekst heeft toegevoegd
|
||||
if ($parsedJson === null) {
|
||||
if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $cleanContent, $matchJson)) {
|
||||
$parsedJson = json_decode($matchJson[0], true);
|
||||
} elseif (preg_match('/\[(?:[^\[\]]|(?R))*\]/s', $cleanContent, $matchJson)) {
|
||||
$parsedJson = json_decode($matchJson[0], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$promptTokens = (int)($response['json']['usageMetadata']['promptTokenCount'] ?? 0);
|
||||
$completionTokens = (int)($response['json']['usageMetadata']['candidatesTokenCount'] ?? 0);
|
||||
if ($promptTokens === 0 && $completionTokens === 0) {
|
||||
// Schatting obv lengte indien niet geretourneerd
|
||||
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
|
||||
$completionTokens = max(1, (int)(strlen($cleanContent) / 4));
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $cleanContent,
|
||||
'json' => $parsedJson,
|
||||
'model' => $successfulModel,
|
||||
'provider' => 'gemini',
|
||||
'prompt_tokens' => $promptTokens,
|
||||
'completion_tokens' => $completionTokens,
|
||||
'total_tokens' => ($promptTokens + $completionTokens),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Voert een gestructureerde chat completion uit met OpenAI
|
||||
*
|
||||
* @param array $messages
|
||||
* @param array $options
|
||||
* @param array $aiSettings
|
||||
* @return array
|
||||
*/
|
||||
function fociz_ai_chat_openai(array $messages, array $options, array $aiSettings): array {
|
||||
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
|
||||
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gpt-4o-mini';
|
||||
$temperature = $options['temperature'] ?? 0.3;
|
||||
$maxTokens = $options['max_tokens'] ?? 1500;
|
||||
$jsonMode = !empty($options['json_mode']);
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen OpenAI API-sleutel ingevuld of geconfigureerd.',
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'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' => 20,
|
||||
]);
|
||||
|
||||
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
|
||||
$errMsg = null;
|
||||
if (!empty($response['json']['error']['message'])) {
|
||||
$errMsg = $response['json']['error']['message'];
|
||||
} elseif (!empty($response['error'])) {
|
||||
$errMsg = $response['error'];
|
||||
} elseif (!empty($response['body'])) {
|
||||
$errMsg = "OpenAI fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
|
||||
} else {
|
||||
$errMsg = "OpenAI API aanroep mislukt (HTTP {$response['status']})";
|
||||
}
|
||||
error_log("fociz_ai_chat_openai fout: {$errMsg}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$rawContent = trim($response['json']['choices'][0]['message']['content']);
|
||||
$parsedJson = null;
|
||||
|
||||
if ($jsonMode) {
|
||||
$parsedJson = json_decode($rawContent, true);
|
||||
}
|
||||
|
||||
$promptTokens = (int)($response['json']['usage']['prompt_tokens'] ?? 0);
|
||||
$completionTokens = (int)($response['json']['usage']['completion_tokens'] ?? 0);
|
||||
if ($promptTokens === 0 && $completionTokens === 0) {
|
||||
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
|
||||
$completionTokens = max(1, (int)(strlen($rawContent) / 4));
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $rawContent,
|
||||
'json' => $parsedJson,
|
||||
'model' => $response['json']['model'] ?? $model,
|
||||
'provider' => 'openai',
|
||||
'prompt_tokens' => $promptTokens,
|
||||
'completion_tokens' => $completionTokens,
|
||||
'total_tokens' => ($promptTokens + $completionTokens),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoofdfunctie: Voert een gestructureerde chat completion uit
|
||||
* Routeert automatisch naar Google Gemini Pro of OpenAI en dwingt ALTIJD
|
||||
* de Zero-Data Leakage privacy firewall af.
|
||||
*
|
||||
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
|
||||
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int, 'user_id' => ?int]
|
||||
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string, 'provider' => ?string]
|
||||
*/
|
||||
function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
// 1. ABSOLUTE ZERO-DATA LEAKAGE PRIVACY CONTROLE:
|
||||
// Filter ALLE Jira ticket keys, Atlassian URLs, Teams links en Mail adressen/headers
|
||||
fociz_ai_enforce_privacy_firewall($messages);
|
||||
|
||||
$userId = $options['user_id'] ?? null;
|
||||
$aiSettings = get_ai_settings($userId);
|
||||
|
||||
if (empty($aiSettings['ai_api_key'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen AI API-sleutel geconfigureerd. Stel Google Gemini Pro of OpenAI in via Systeembeheer of je profiel.',
|
||||
];
|
||||
}
|
||||
|
||||
$provider = strtolower($aiSettings['ai_provider'] ?? 'gemini');
|
||||
$model = strtolower($aiSettings['ai_model'] ?? '');
|
||||
|
||||
// Routering: als provider gemini is of model gemini bevat, stuur naar Google Gemini
|
||||
if ($provider === 'gemini' || strpos($model, 'gemini') !== false) {
|
||||
$res = fociz_ai_chat_gemini($messages, $options, $aiSettings);
|
||||
} else {
|
||||
$res = fociz_ai_chat_openai($messages, $options, $aiSettings);
|
||||
}
|
||||
|
||||
// Verbruiksstatistieken registreren bij geslaagde aanroep
|
||||
if (!empty($res['success'])) {
|
||||
$usedProvider = $res['provider'] ?? $provider;
|
||||
$usedModel = $res['model'] ?? $model;
|
||||
$pTokens = (int)($res['prompt_tokens'] ?? 0);
|
||||
$cTokens = (int)($res['completion_tokens'] ?? 0);
|
||||
record_ai_usage($usedProvider, $usedModel, $pTokens, $cTokens, $userId);
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Werkt een brainstorm idee uit tot 3-5 concrete, behapbare items
|
||||
* voor een Kanban Bord, Sprint/Tijdlijn of Dagplanner.
|
||||
*
|
||||
* @param string $ideaContent
|
||||
* @param string $sessionTitle
|
||||
* @param string $theme
|
||||
* @param int|null $userId
|
||||
* @return array ['success' => bool, 'items' => array, 'error' => ?string]
|
||||
*/
|
||||
function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '', string $theme = '', ?int $userId = null): array {
|
||||
$systemPrompt = "Je bent een senior Agile/Scrum product specialist en technisch ontwerper.\n"
|
||||
. "Je taak is om een brainstorm idee of concept om te zetten in 3 tot 5 concrete, behapbare en direct uitvoerbare taken voor een agile team.\n"
|
||||
. "Elk item moet een heldere taak zijn die binnen één dag of sprintblok voltooid kan worden.\n\n"
|
||||
. "Formateer de uitvoer als een JSON-object met exact deze structuur:\n"
|
||||
. "{\n"
|
||||
. " \"summary\": \"Korte samenvatting van de aanpak (1 zin)\",\n"
|
||||
. " \"items\": [\n"
|
||||
. " {\n"
|
||||
. " \"title\": \"Actiegerichte titel (bijv: API endpoint ontwerpen voor authenticatie)\",\n"
|
||||
. " \"description\": \"Korte toelichting en 2 concrete acceptatiecriteria\",\n"
|
||||
. " \"estimated_minutes\": 45,\n"
|
||||
. " \"priority\": \"P1\",\n"
|
||||
. " \"color\": \"indigo\",\n"
|
||||
. " \"category\": \"Backend\"\n"
|
||||
. " }\n"
|
||||
. " ]\n"
|
||||
. "}\n\n"
|
||||
. "Regels:\n"
|
||||
. "- Prioriteit moet 'P1' (hoog/focus), 'P2' (medium) of 'P3' (laag) zijn.\n"
|
||||
. "- Kleur moet gekozen worden uit: 'indigo', 'blue', 'emerald', 'amber', 'rose', 'purple'.\n"
|
||||
. "- Duur in minuten: 15, 30, 45, 60, 90 of 120.\n"
|
||||
. "- Categorie: 'Frontend', 'Backend', 'Design', 'Onderzoek', of 'Testing'.\n"
|
||||
. "- Retourneer UITSCHLUITEND geldige JSON.";
|
||||
|
||||
$userPrompt = "Brainstorm Sessie: " . ($sessionTitle ?: 'Algemeen') . "\n"
|
||||
. "Thema: " . ($theme ?: 'Productontwikkeling') . "\n\n"
|
||||
. "Uit te werken idee:\n\"" . $ideaContent . "\"\n\n"
|
||||
. "Splits dit idee op in 3 tot 5 concrete items.";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], [
|
||||
'json_mode' => true,
|
||||
'temperature' => 0.4,
|
||||
'max_tokens' => 2000,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
|
||||
// Fallback: Als AI na alle modelpogingen niet reageert, creëer 3 logische taken zodat de workflow direct doorloopt
|
||||
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
|
||||
return [
|
||||
'success' => true,
|
||||
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
|
||||
'items' => [
|
||||
[
|
||||
'title' => "Onderzoek & specificatie: {$shortTitle}",
|
||||
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
|
||||
'estimated_minutes' => 45,
|
||||
'priority' => 'P1',
|
||||
'color' => 'indigo',
|
||||
'category' => 'Onderzoek'
|
||||
],
|
||||
[
|
||||
'title' => "Realisatie & uitwerking: {$shortTitle}",
|
||||
'description' => "Implementatie en concrete uitvoering van het idee.",
|
||||
'estimated_minutes' => 90,
|
||||
'priority' => 'P2',
|
||||
'color' => 'blue',
|
||||
'category' => 'Frontend'
|
||||
],
|
||||
[
|
||||
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
|
||||
'description' => "Testen, feedback verwerken en afronden.",
|
||||
'estimated_minutes' => 30,
|
||||
'priority' => 'P2',
|
||||
'color' => 'emerald',
|
||||
'category' => 'Testing'
|
||||
]
|
||||
],
|
||||
'model' => 'Smart Fallback',
|
||||
'is_fallback' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$parsed = $aiRes['json'] ?? null;
|
||||
$items = [];
|
||||
$summary = '';
|
||||
|
||||
if (is_array($parsed)) {
|
||||
if (!empty($parsed['items']) && is_array($parsed['items'])) {
|
||||
$items = $parsed['items'];
|
||||
} elseif (!empty($parsed['tasks']) && is_array($parsed['tasks'])) {
|
||||
$items = $parsed['tasks'];
|
||||
} elseif (!empty($parsed['taken']) && is_array($parsed['taken'])) {
|
||||
$items = $parsed['taken'];
|
||||
} elseif (!empty($parsed['cards']) && is_array($parsed['cards'])) {
|
||||
$items = $parsed['cards'];
|
||||
} elseif (array_is_list($parsed)) {
|
||||
$items = $parsed;
|
||||
}
|
||||
$summary = (string)($parsed['summary'] ?? '');
|
||||
}
|
||||
|
||||
// Als JSON parsing toch leeg was, probeer regex fallback op ruwe content
|
||||
if (empty($items) && !empty($aiRes['content'])) {
|
||||
if (preg_match('/\[\s*\{.*\}\s*\]/s', $aiRes['content'], $mArr)) {
|
||||
$decodedArr = json_decode($mArr[0], true);
|
||||
if (is_array($decodedArr)) {
|
||||
$items = $decodedArr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
|
||||
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
|
||||
return [
|
||||
'success' => true,
|
||||
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
|
||||
'items' => [
|
||||
[
|
||||
'title' => "Onderzoek & specificatie: {$shortTitle}",
|
||||
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
|
||||
'estimated_minutes' => 45,
|
||||
'priority' => 'P1',
|
||||
'color' => 'indigo',
|
||||
'category' => 'Onderzoek'
|
||||
],
|
||||
[
|
||||
'title' => "Realisatie & uitwerking: {$shortTitle}",
|
||||
'description' => "Implementatie en concrete uitvoering van het idee.",
|
||||
'estimated_minutes' => 90,
|
||||
'priority' => 'P2',
|
||||
'color' => 'blue',
|
||||
'category' => 'Frontend'
|
||||
],
|
||||
[
|
||||
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
|
||||
'description' => "Testen, feedback verwerken en afronden.",
|
||||
'estimated_minutes' => 30,
|
||||
'priority' => 'P2',
|
||||
'color' => 'emerald',
|
||||
'category' => 'Testing'
|
||||
]
|
||||
],
|
||||
'model' => 'Smart Fallback',
|
||||
'is_fallback' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'summary' => $summary ?: 'Dit idee is opgesplitst in behapbare deeltaken.',
|
||||
'items' => $items,
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Categoriseert sticky notes automatisch in:
|
||||
* - 'Ideeën'
|
||||
* - 'Knelpunten'
|
||||
* - 'Oplossingen'
|
||||
* - 'Quick Wins'
|
||||
*/
|
||||
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards, ?int $userId = null): 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, 'user_id' => $userId]);
|
||||
|
||||
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, ?int $userId = null, string $description = ''): array {
|
||||
$descContext = !empty($description) ? "\nContext en briefing van de sessie:\n" . trim($description) . "\n" : "";
|
||||
|
||||
$systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n"
|
||||
. "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n"
|
||||
. $descContext
|
||||
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een post-it staan).\n"
|
||||
. "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is.";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën op basis van de sessie en context."],
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
|
||||
$ideas = [
|
||||
"Gebruikerservaring en flow optimaliseren voor {$sessionTitle}",
|
||||
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
|
||||
"Koppelingen en integraties verkennen voor snellere afhandeling",
|
||||
"Feedback- en validatielus inrichten met eindgebruikers"
|
||||
];
|
||||
return ['success' => true, 'ideas' => array_slice($ideas, 0, max(2, $count)), 'is_fallback' => true];
|
||||
}
|
||||
|
||||
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyseert de volledige sessie briefing en genereert een complete set post-its verdeeld over:
|
||||
* - Ideeën & Concepten (amber)
|
||||
* - Knelpunten & Uitdagingen (rose)
|
||||
* - Oplossingen & Features (teal)
|
||||
* - Quick Wins (indigo)
|
||||
*/
|
||||
function fociz_ai_generate_session_board(string $sessionTitle, string $theme, string $description = '', ?int $userId = null): array {
|
||||
$descContext = !empty($description)
|
||||
? "Uitgebreide sessiebeschrijving en briefing:\n" . trim($description) . "\n\n"
|
||||
: "Geen aanvullende briefing opgegeven; baseer je op de titel en het thema.\n\n";
|
||||
|
||||
$systemPrompt = "Je bent een ervaren agile facilitator en strategisch adviseur.\n"
|
||||
. "Je analyseert de briefing van een brainstormsessie getiteld '{$sessionTitle}' (Thema: '{$theme}').\n"
|
||||
. $descContext
|
||||
. "Jouw taak is om de sessie direct uit te werken tot een rijk, concreet whiteboard met post-its over exact 4 categorieën:\n"
|
||||
. "1. 'Ideeën': Vernieuwende concepten, functionaliteiten en strategische kansen (2 tot 4 post-its).\n"
|
||||
. "2. 'Knelpunten': Concreet benoemde knelpunten, risico's, blokkades of gebruikersfrustraties (2 tot 4 post-its).\n"
|
||||
. "3. 'Oplossingen': Structurele oplossingen, architectuurkeuzes of procesaanpassingen voor de knelpunten (2 tot 4 post-its).\n"
|
||||
. "4. 'Quick Wins': Laagdrempelige acties die met minimale inspanning direct resultaat opleveren (2 tot 3 post-its).\n\n"
|
||||
. "Regels per post-it:\n"
|
||||
. "- Formuleer scherp, helder en direct to-the-point (1-2 zinnen per kaartje alsof het op een fysieke post-it staat).\n"
|
||||
. "- Sluit naadloos aan op de verstrekte briefing en achtergrondinformatie.\n\n"
|
||||
. "Retourneer UITSLUITEND een geldig JSON-object met het veld 'cards', waarbij elk item bestaat uit:\n"
|
||||
. "- 'content': string (de tekst op de post-it)\n"
|
||||
. "- 'category': exact één van ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins']";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."],
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['cards']) || !is_array($aiRes['json']['cards'])) {
|
||||
return [
|
||||
'success' => true,
|
||||
'cards' => [
|
||||
['content' => "Nieuwe visie en kernfunctionaliteit uitwerken voor {$sessionTitle}", 'category' => 'Ideeën'],
|
||||
['content' => "Gebruikerservaring en self-service flow versterken", 'category' => 'Ideeën'],
|
||||
['content' => "Onduidelijke randvoorwaarden of ontbrekende specificaties", 'category' => 'Knelpunten'],
|
||||
['content' => "Handmatige stappen die vertraging en overbelasting veroorzaken", 'category' => 'Knelpunten'],
|
||||
['content' => "Geautomatiseerd proces en herbruikbare templates inrichten", 'category' => 'Oplossingen'],
|
||||
['content' => "Duidelijke acceptatiecriteria en testvalidatie vooraf", 'category' => 'Oplossingen'],
|
||||
['content' => "Quick win: Korte validatiesessie organiseren met het team", 'category' => 'Quick Wins'],
|
||||
['content' => "Quick win: Dashboard overzicht en checklist direct klaarzetten", 'category' => 'Quick Wins'],
|
||||
],
|
||||
'is_fallback' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'cards' => $aiRes['json']['cards']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zet brainstorm kaartjes om in een scherp Roadmap Item
|
||||
*/
|
||||
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '', ?int $userId = null): array {
|
||||
$systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n"
|
||||
. "Retourneer een JSON-object met:\n"
|
||||
. "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n"
|
||||
. "- '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, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
|
||||
// Intelligente fallback bij storing / high demand zodat roadmap conversie nooit faalt
|
||||
$lines = explode("\n", trim($content));
|
||||
$firstLine = trim($lines[0] ?? 'Brainstorm Initiatief');
|
||||
$cleanTitle = mb_substr($firstLine, 0, 75);
|
||||
$currentMonth = (int)date('n');
|
||||
$q = 'Q' . ceil($currentMonth / 3);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'item' => [
|
||||
'title' => $cleanTitle,
|
||||
'description' => $content,
|
||||
'priority' => 'HIGH',
|
||||
'type' => 'FEATURE',
|
||||
'quarter' => $q,
|
||||
],
|
||||
'is_fallback' => true,
|
||||
'notice' => 'AI model had tijdelijke piekdruk; metadata automatisch ingevuld.'
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'item' => $aiRes['json']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berekent een slimme dagindeling voor open taken rondom geanonimiseerde bezette tijdblokken.
|
||||
* ZERO-DATA LEAKAGE: Bevat NOOIT meetingtitels, inhoud of e-mailadressen!
|
||||
*/
|
||||
function fociz_ai_smart_schedule(array $tasks, array $busyBlocks, array $workdaySettings, ?int $userId = null): array {
|
||||
$startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0];
|
||||
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:00')[0];
|
||||
$lunchStart = $workdaySettings['lunch_start'] ?? '12:30';
|
||||
$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00';
|
||||
|
||||
// Anonimiseer taken: stuur alleen ID, titel (gefilterd door firewall), prioriteit en duur
|
||||
$sanitizedTasks = array_map(fn($t) => [
|
||||
'id' => (int)$t['id'],
|
||||
'title' => (string)$t['title'],
|
||||
'priority' => $t['priority'] ?? 'P2',
|
||||
'duration_minutes' => (int)($t['duration_minutes'] ?? 30),
|
||||
], $tasks);
|
||||
|
||||
// Anonimiseer bezette blokken: GEEN titels of deelnemers, enkel tijdstippen!
|
||||
$sanitizedBusy = array_map(fn($b) => [
|
||||
'start' => $b['start'] ?? '09:00',
|
||||
'end' => $b['end'] ?? '10:00',
|
||||
], $busyBlocks);
|
||||
|
||||
$systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n"
|
||||
. "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n"
|
||||
. "Regels:\n"
|
||||
. "1. Plan GEEN taken tijdens bezette blokken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
|
||||
. "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n"
|
||||
. "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n"
|
||||
. "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}.";
|
||||
|
||||
$userPrompt = "Bezette tijdsintervallen (agenda):\n" . json_encode($sanitizedBusy, JSON_UNESCAPED_UNICODE) . "\n\n"
|
||||
. "In te plannen taken:\n" . json_encode($sanitizedTasks, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
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']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een persoonlijke AI dagbriefing op basis van interne Fociz taken.
|
||||
* ZERO-DATA LEAKAGE: Absoluut GEEN Jira, Mail (Outlook) of Teams data!
|
||||
*
|
||||
* @param string $userName Naam van de gebruiker
|
||||
* @param string $date Datum (Y-m-d)
|
||||
* @param array $tasks Openstaande interne taken van vandaag
|
||||
* @param array $busyBlocks Geanonimiseerde bezette tijdsblokken (start/end)
|
||||
* @param array $unfinishedYest Gisteren niet voltooide taken
|
||||
* @param int|null $userId
|
||||
* @return array ['success' => bool, 'greeting' => string, 'focus_task' => string, 'tip' => string, 'warnings' => array]
|
||||
*/
|
||||
function fociz_ai_daily_briefing(
|
||||
string $userName,
|
||||
string $date,
|
||||
array $tasks,
|
||||
array $busyBlocks = [],
|
||||
array $unfinishedYest = [],
|
||||
?int $userId = null
|
||||
): array {
|
||||
$dutchDays = ['', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
|
||||
$dayName = $dutchDays[(int)date('N', strtotime($date))] ?? 'vandaag';
|
||||
$dateLabel = $dayName . ' ' . date('j', strtotime($date)) . ' ' . date('F', strtotime($date));
|
||||
|
||||
// Alleen pure Fociz taken samenvatten
|
||||
$taskSummary = array_map(fn($t) => [
|
||||
'title' => $t['title'],
|
||||
'priority' => $t['priority'] ?? 'P2',
|
||||
'time' => $t['time_block_start'] ?? null,
|
||||
'duration' => (int)($t['duration_minutes'] ?? 30),
|
||||
], array_slice($tasks, 0, 10));
|
||||
|
||||
$yesterdaySummary = array_map(fn($t) => $t['title'], array_slice($unfinishedYest, 0, 5));
|
||||
|
||||
$systemPrompt = "Je bent de persoonlijke AI Co-Pilot van {$userName} in Fociz. "
|
||||
. "Schrijf een korte, energieke en persoonlijke dagbriefing in het Nederlands voor {$dateLabel}. "
|
||||
. "Wees direct, concreet en vriendelijk. Gebruik geen markdown headers, geen bullet symbolen zoals *, -, •. "
|
||||
. "Geef 1-2 zinnen dagopening + 1 zin over de grootste prioriteit + 1 praktische focustip. "
|
||||
. "Maximaal 60 woorden totaal. "
|
||||
. "Retourneer een JSON-object met:\n"
|
||||
. "- 'greeting': openingszin (max 20 woorden)\n"
|
||||
. "- 'focus_task': de meest urgente taak vandaag (max 15 woorden)\n"
|
||||
. "- 'tip': één concrete focustip voor vandaag (max 20 woorden)\n"
|
||||
. "- 'warnings': array van strings, max 2 items (bijv. gisteren niet afgerond of overvolle dag)";
|
||||
|
||||
$userPrompt = json_encode([
|
||||
'date' => $dateLabel,
|
||||
'tasks_today' => $taskSummary,
|
||||
'busy_blocks_count' => count($busyBlocks),
|
||||
'unfinished_yesterday' => $yesterdaySummary,
|
||||
'total_tasks' => count($tasks),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true, 'temperature' => 0.6, 'max_tokens' => 300, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen dagbriefing genereren'];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'greeting' => $aiRes['json']['greeting'] ?? "Goedemorgen {$userName}, een nieuwe dag!",
|
||||
'focus_task' => $aiRes['json']['focus_task'] ?? '',
|
||||
'tip' => $aiRes['json']['tip'] ?? '',
|
||||
'warnings' => $aiRes['json']['warnings'] ?? [],
|
||||
'model' => $aiRes['model'] ?? 'AI',
|
||||
];
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Board & Timeline Integratie Helper
|
||||
* Biedt functies om taken / e-mails / Jira tickets direct als kaarten toe te voegen aan Kanban-borden en Project Tijdlijnen.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
|
||||
/**
|
||||
* Haalt alle actieve borden op waar de gebruiker toegang toe heeft, inclusief kolommen (lanes).
|
||||
*/
|
||||
function fociz_get_user_boards_with_lanes(PDO $db, int $userId, bool $isAdmin): array {
|
||||
if ($isAdmin) {
|
||||
$stmt = $db->query("
|
||||
SELECT b.id, b.title, b.description, b.created_by_id
|
||||
FROM boards b
|
||||
WHERE b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$boards = $stmt->fetchAll();
|
||||
} else {
|
||||
$stmt = $db->prepare("
|
||||
SELECT b.id, b.title, b.description, b.created_by_id
|
||||
FROM boards b
|
||||
JOIN board_members bm ON b.id = bm.board_id
|
||||
WHERE bm.user_id = ? AND b.is_archived = 0
|
||||
ORDER BY b.id ASC
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$boards = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
if (empty($boards)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$boardIds = array_map(fn($b) => (int)$b['id'], $boards);
|
||||
$placeholders = implode(',', array_fill(0, count($boardIds), '?'));
|
||||
|
||||
$laneStmt = $db->prepare("
|
||||
SELECT id, board_id, title, color, position
|
||||
FROM board_lanes
|
||||
WHERE board_id IN ({$placeholders})
|
||||
ORDER BY position ASC, id ASC
|
||||
");
|
||||
$laneStmt->execute($boardIds);
|
||||
$lanes = $laneStmt->fetchAll();
|
||||
|
||||
$lanesByBoard = [];
|
||||
foreach ($lanes as $lane) {
|
||||
$bId = (int)$lane['board_id'];
|
||||
$lanesByBoard[$bId][] = $lane;
|
||||
}
|
||||
|
||||
foreach ($boards as &$b) {
|
||||
$bId = (int)$b['id'];
|
||||
$b['lanes'] = $lanesByBoard[$bId] ?? [];
|
||||
}
|
||||
unset($b);
|
||||
|
||||
return $boards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voegt een item toe aan een Kanban bord en optioneel aan de Project Tijdlijn.
|
||||
*
|
||||
* @param PDO $db Database verbinding
|
||||
* @param int $userId ID van de gebruiker
|
||||
* @param bool $isAdmin Is gebruiker admin
|
||||
* @param array $data Data array met:
|
||||
* - board_id (int)
|
||||
* - lane_id (int, optioneel - defaults naar 1e lane)
|
||||
* - title (string)
|
||||
* - description (string, optioneel)
|
||||
* - priority ('P1'|'P2'|'P3', optioneel)
|
||||
* - color (string, optioneel)
|
||||
* - schedule_on_timeline (bool, optioneel)
|
||||
* - start_date (string 'YYYY-MM-DD', optioneel)
|
||||
* - end_date (string 'YYYY-MM-DD', optioneel)
|
||||
* - start_time (string 'HH:MM', optioneel)
|
||||
* - end_time (string 'HH:MM', optioneel)
|
||||
* - sync_outlook (bool, optioneel)
|
||||
* @return array ['success' => bool, 'card_id' => int, 'message' => string]
|
||||
*/
|
||||
function fociz_add_item_to_board_and_timeline(PDO $db, int $userId, bool $isAdmin, array $data): array {
|
||||
$boardId = (int)($data['board_id'] ?? 0);
|
||||
$title = trim($data['title'] ?? '');
|
||||
if ($boardId <= 0 || empty($title)) {
|
||||
return ['success' => false, 'error' => 'Geldig bord en titel zijn verplicht.'];
|
||||
}
|
||||
|
||||
// Autorisatiecheck
|
||||
if (!$isAdmin) {
|
||||
$stmtCheck = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmtCheck->execute([$boardId, $userId]);
|
||||
if ((int)$stmtCheck->fetchColumn() === 0) {
|
||||
return ['success' => false, 'error' => 'Geen toegang tot dit bord.'];
|
||||
}
|
||||
}
|
||||
|
||||
$laneId = (int)($data['lane_id'] ?? 0);
|
||||
if ($laneId <= 0) {
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
|
||||
$stmtLane->execute([$boardId]);
|
||||
$laneId = (int)$stmtLane->fetchColumn();
|
||||
}
|
||||
|
||||
if ($laneId <= 0) {
|
||||
return ['success' => false, 'error' => 'Het geselecteerde bord heeft geen kolommen.'];
|
||||
}
|
||||
|
||||
$description = trim($data['description'] ?? '');
|
||||
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
|
||||
$color = in_array($data['color'] ?? '', ['yellow', 'blue', 'green', 'purple', 'pink', 'orange', 'slate', 'indigo']) ? $data['color'] : 'blue';
|
||||
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
|
||||
$stmtInsert = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmtInsert->execute([$boardId, $laneId, $title, $description, $userId, $priority, $color, $maxPos + 1, $userId]);
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
|
||||
// Optioneel inplannen op Tijdlijn / Gantt
|
||||
$scheduleOnTimeline = !empty($data['schedule_on_timeline']);
|
||||
if ($scheduleOnTimeline && $cardId > 0) {
|
||||
$startDate = !empty($data['start_date']) ? trim($data['start_date']) : date('Y-m-d');
|
||||
$endDate = !empty($data['end_date']) ? trim($data['end_date']) : $startDate;
|
||||
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : '09:00';
|
||||
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : '17:00';
|
||||
$daysOfWeek = trim($data['days_of_week'] ?? '1,2,3,4,5');
|
||||
|
||||
$stmtSched = $db->prepare("
|
||||
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
|
||||
VALUES (?, 'DAILY_RANGE', ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
start_date = VALUES(start_date),
|
||||
end_date = VALUES(end_date),
|
||||
start_time = VALUES(start_time),
|
||||
end_time = VALUES(end_time),
|
||||
days_of_week = VALUES(days_of_week)
|
||||
");
|
||||
$stmtSched->execute([$cardId, $startDate, $endDate, $startTime, $endTime, $daysOfWeek]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'card_id' => $cardId,
|
||||
'board_id' => $boardId,
|
||||
'lane_id' => $laneId,
|
||||
'scheduled_on_timeline' => $scheduleOnTimeline,
|
||||
'message' => $scheduleOnTimeline
|
||||
? 'Item succesvol toegevoegd aan het Bord en ingepland op de Tijdlijn!'
|
||||
: 'Item succesvol toegevoegd aan het Bord!'
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Centrale Caching Laag voor Fociz
|
||||
* Biedt een supersnelle database- én in-memory transiënte cache voor externe API-aanroepen (Microsoft Graph, Jira Cloud).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
function ensure_app_cache_table(?PDO $db = null): void {
|
||||
static $ensured = false;
|
||||
if ($ensured) return;
|
||||
|
||||
try {
|
||||
$pdo = $db ?: get_db_connection();
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS `app_cache` (
|
||||
`cache_key` VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
`cache_value` MEDIUMTEXT NOT NULL,
|
||||
`expires_at` INT UNSIGNED NOT NULL,
|
||||
INDEX `idx_app_cache_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
");
|
||||
$ensured = true;
|
||||
} catch (Throwable $e) {
|
||||
error_log("ensure_app_cache_table error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt een item op uit de cache.
|
||||
* Controleert eerst het runtime statisch geheugen, vervolgens MariaDB.
|
||||
*/
|
||||
function fociz_cache_get(string $key, $default = null) {
|
||||
static $memoryCache = [];
|
||||
|
||||
$now = time();
|
||||
|
||||
// 1. Runtime memory check (0ms)
|
||||
if (isset($memoryCache[$key])) {
|
||||
if ($memoryCache[$key]['expires_at'] > $now) {
|
||||
return $memoryCache[$key]['value'];
|
||||
}
|
||||
unset($memoryCache[$key]);
|
||||
}
|
||||
|
||||
// 2. Database check (< 0.5ms)
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_app_cache_table($db);
|
||||
|
||||
$stmt = $db->prepare("SELECT cache_value, expires_at FROM app_cache WHERE cache_key = ?");
|
||||
$stmt->execute([$key]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row) {
|
||||
if ((int)$row['expires_at'] > $now) {
|
||||
$decoded = json_decode($row['cache_value'], true);
|
||||
$val = ($decoded !== null || $row['cache_value'] === 'null') ? $decoded : $row['cache_value'];
|
||||
$memoryCache[$key] = [
|
||||
'value' => $val,
|
||||
'expires_at' => (int)$row['expires_at']
|
||||
];
|
||||
return $val;
|
||||
} else {
|
||||
// Verlopen item direct opruimen
|
||||
$del = $db->prepare("DELETE FROM app_cache WHERE cache_key = ?");
|
||||
$del->execute([$key]);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_cache_get error for {$key}: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slaat een item op in de cache met een bepaalde levensduur in seconden.
|
||||
*/
|
||||
function fociz_cache_set(string $key, $value, int $ttlSeconds = 60): bool {
|
||||
static $memoryCache = [];
|
||||
|
||||
$now = time();
|
||||
$expiresAt = $now + max(1, $ttlSeconds);
|
||||
|
||||
// 1. Sla op in runtime memory
|
||||
$memoryCache[$key] = [
|
||||
'value' => $value,
|
||||
'expires_at' => $expiresAt
|
||||
];
|
||||
|
||||
// 2. Sla op in database
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_app_cache_table($db);
|
||||
|
||||
$valToStore = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO app_cache (cache_key, cache_value, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
cache_value = VALUES(cache_value),
|
||||
expires_at = VALUES(expires_at)
|
||||
");
|
||||
return $stmt->execute([$key, $valToStore, $expiresAt]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_cache_set error for {$key}: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert een specifiek item uit de cache.
|
||||
*/
|
||||
function fociz_cache_delete(string $key): bool {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_app_cache_table($db);
|
||||
$stmt = $db->prepare("DELETE FROM app_cache WHERE cache_key = ?");
|
||||
return $stmt->execute([$key]);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert alle items met een bepaald voorvoegsel (bijv. 'ms_events_1_')
|
||||
*/
|
||||
function fociz_cache_delete_pattern(string $prefix): bool {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_app_cache_table($db);
|
||||
$stmt = $db->prepare("DELETE FROM app_cache WHERE cache_key LIKE ?");
|
||||
return $stmt->execute([$prefix . '%']);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert alle verlopen items actief uit de cache tabel (AVG/GDPR opschoning)
|
||||
*/
|
||||
function fociz_cache_cleanup_expired(?PDO $db = null): int {
|
||||
try {
|
||||
$pdo = $db ?: get_db_connection();
|
||||
ensure_app_cache_table($pdo);
|
||||
$stmt = $pdo->prepare("DELETE FROM app_cache WHERE expires_at < ?");
|
||||
$stmt->execute([time()]);
|
||||
return $stmt->rowCount();
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_cache_cleanup_expired error: " . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Probabilistische automatische GC (5% kans bij elke script execution)
|
||||
if (mt_rand(1, 20) === 1) {
|
||||
fociz_cache_cleanup_expired();
|
||||
}
|
||||
@@ -14,28 +14,32 @@
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
// Standaard waarde voor de main branch: 'live' (bepaald via centrale 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>
|
||||
<!-- LIVE OMGEVING (GROEN) -->
|
||||
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/95 border border-emerald-500/70 shadow-lg shadow-emerald-500/25 backdrop-blur-md group">
|
||||
<!-- Pulsing Green 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-emerald-400 opacity-80"></span>
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500 shadow-sm shadow-emerald-500"></span>
|
||||
</span>
|
||||
|
||||
<span class="text-[10px] font-bold tracking-wider text-teal-400 uppercase flex items-center space-x-1">
|
||||
<span class="text-[11px] font-black tracking-wider text-emerald-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">
|
||||
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.2 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||
MAIN
|
||||
</span>
|
||||
</div>
|
||||
@@ -52,7 +56,7 @@ $isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod'
|
||||
<span>Development</span>
|
||||
</span>
|
||||
|
||||
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.5 rounded bg-red-500/20 text-red-300 border border-red-500/30">
|
||||
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.2 rounded bg-red-500/20 text-red-300 border border-red-500/30">
|
||||
DEV
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,64 +1,18 @@
|
||||
</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">
|
||||
<footer class="border-t border-slate-800/80 py-6 text-center text-xs text-slate-500">
|
||||
<div class="max-w-7xl mx-auto px-4 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 class="font-bold text-slate-400">Fociz</span>
|
||||
<span>•</span>
|
||||
<span class="text-slate-500 dark:text-slate-400">Focus & Structuur Systeem</span>
|
||||
<span>Focus & Structuur Systeem</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400 dark:text-slate-500">Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
|
||||
<span>Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Fociz Agenda Notificaties Engine -->
|
||||
<script src="/assets/js/notifications.js"></script>
|
||||
|
||||
<?php
|
||||
$sessionIdleTimeout = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
?>
|
||||
<!-- Fociz Inactiviteitswaarschuwing Modal (ISO 27001 / NIST SP 800-63B) -->
|
||||
<div id="focizSessionModal" class="fixed inset-0 z-[9999] bg-slate-950/80 backdrop-blur-md hidden items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-[#111622] border border-amber-500/30 dark:border-amber-500/30 rounded-3xl w-full max-w-md shadow-2xl p-6 sm:p-7 text-center relative overflow-hidden">
|
||||
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 dark:bg-amber-500/20 border border-amber-500/30 text-amber-600 dark:text-amber-400 flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-7 h-7 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight mb-2">
|
||||
Sessie verloopt bijna
|
||||
</h3>
|
||||
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-5 leading-relaxed">
|
||||
Wegens inactiviteit wordt u binnenkort automatisch afgemeld conform het beveiligingsbeleid. Klik op <strong class="text-slate-800 dark:text-slate-200">Blijf Ingelogd</strong> om uw sessie te verlengen.
|
||||
</p>
|
||||
|
||||
<div class="inline-flex items-center space-x-2 px-4 py-2 rounded-2xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-700 dark:text-amber-300 font-mono text-xs font-bold mb-6">
|
||||
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke-width="2"></circle>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
<span>Verloopt over:</span>
|
||||
<span id="focizSessionCountdown" class="text-amber-600 dark:text-amber-400 font-black tracking-wider">02:00</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button type="button" id="focizSessionLogoutBtn" class="w-full py-2.5 px-4 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold transition cursor-pointer">
|
||||
Nu Uitloggen
|
||||
</button>
|
||||
<button type="button" id="focizSessionKeepaliveBtn" class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
|
||||
Blijf Ingelogd
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fociz Session Guard Script (NIST SP 800-63B / ISO 27001) -->
|
||||
<script src="/assets/js/session-guard.js" data-timeout="<?= $sessionIdleTimeout ?>"></script>
|
||||
|
||||
<script>
|
||||
// Lucide Icons initialiseren
|
||||
if (typeof lucide !== 'undefined') {
|
||||
@@ -67,3 +21,4 @@ $sessionIdleTimeout = function_exists('get_session_idle_timeout') ? get_session_
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1,47 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* Header & Navigatiebalk voor Fociz (Dual-Mode: Dark & Light, Executive Styling)
|
||||
* Header & Navigatiebalk voor Fociz
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
$currentUser = $currentUser ?? require_auth();
|
||||
$currentUser = $currentUser ?? get_logged_in_user();
|
||||
$currentPage = basename($_SERVER['PHP_SELF']);
|
||||
$currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark';
|
||||
|
||||
// Geef direct het PHP sessieslot vrij voor getoonde pagina's zodat parallelle requests en navigatie nooit blokkeren
|
||||
if (session_status() === PHP_SESSION_ACTIVE && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
|
||||
session_write_close();
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle ?? 'Fociz — Focus, Planning & Roadmap') ?></title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<!-- Anti-Flicker Theme Script: direct in head zodat er nooit een flits optreedt -->
|
||||
<script>
|
||||
(function() {
|
||||
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>
|
||||
|
||||
<title><?= htmlspecialchars($pageTitle ?? 'Fociz — Focus, Dagplanning & Roadmap') ?></title>
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
@@ -51,12 +22,10 @@ if (session_status() === PHP_SESSION_ACTIVE && ($_SERVER['REQUEST_METHOD'] ?? 'G
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
50: '#f0fdf4',
|
||||
500: '#10b981',
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,506 +34,115 @@ if (session_status() === PHP_SESSION_ACTIVE && ($_SERVER['REQUEST_METHOD'] ?? 'G
|
||||
</script>
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<!-- Dagsessie Expiratie Watchdog: reset iedere nacht om 23:59:59 en dwingt dagelijks opnieuw inloggen af -->
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
let hasRedirected = false;
|
||||
const sessionExpiresAt = <?= strtotime('today 23:59:59') ?> * 1000;
|
||||
const checkExpiry = function() {
|
||||
if (hasRedirected) return;
|
||||
if (Date.now() > sessionExpiresAt + 1000) {
|
||||
hasRedirected = true;
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
};
|
||||
|
||||
const msUntilExpiry = sessionExpiresAt - Date.now();
|
||||
if (msUntilExpiry > 0) {
|
||||
setTimeout(checkExpiry, msUntilExpiry + 500);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkExpiry();
|
||||
}
|
||||
});
|
||||
window.addEventListener('focus', checkExpiry);
|
||||
setInterval(checkExpiry, 60000);
|
||||
} catch(e) {}
|
||||
})();
|
||||
</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; }
|
||||
|
||||
/* Modern Block & Card Design System */
|
||||
.fociz-card, .fociz-card-interactive {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.04);
|
||||
border-radius: 1rem;
|
||||
backdrop-filter: blur(12px);
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
html.dark .fociz-card, html.dark .fociz-card-interactive {
|
||||
background: rgba(17, 22, 34, 0.88);
|
||||
border: 1px solid rgba(30, 41, 59, 0.7);
|
||||
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.fociz-card-interactive {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.fociz-card-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.06);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
html.dark .fociz-card-interactive:hover {
|
||||
box-shadow: 0 12px 28px -5px rgba(0, 0, 0, 0.5), 0 0 20px rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
background-color: #090d16;
|
||||
color: #f1f5f9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #0f172a; }
|
||||
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white transition-colors duration-150">
|
||||
<body class="min-h-screen flex flex-col antialiased selection:bg-emerald-500 selection:text-white">
|
||||
|
||||
<?php
|
||||
// Include omgevingsindicator (Dev = Rood, Live = Groen)
|
||||
// Include flashy 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">
|
||||
<header class="sticky top-0 z-40 bg-[#0c121e]/90 backdrop-blur-md border-b border-slate-800/80">
|
||||
<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 (uitgelijnd met de widgets / content container) -->
|
||||
<div class="flex items-center space-x-4 lg:space-x-6 shrink-0">
|
||||
<a href="/planner.php" class="flex items-center space-x-2.5 group shrink-0">
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 flex items-center justify-center text-white font-black text-sm shadow-sm shadow-indigo-500/20 group-hover:scale-105 transition">
|
||||
<!-- Brand Logo (met linker padding zodat het 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-9 h-9 rounded-xl bg-gradient-to-tr from-emerald-600 to-teal-400 flex items-center justify-center text-slate-950 font-black text-lg shadow-md shadow-emerald-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>
|
||||
<span class="text-xl font-bold tracking-tight text-white leading-none">Fociz</span>
|
||||
<span class="text-[10px] text-emerald-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="layout-dashboard" class="w-4 h-4"></i>
|
||||
<span>Fociz</span>
|
||||
<a href="/planner.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'planner.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||
<span>Dagplanning</span>
|
||||
</a>
|
||||
|
||||
<a href="/mail.php" class="relative flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'mail.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="mail" class="w-4 h-4 text-sky-500"></i>
|
||||
<span>Mail</span>
|
||||
<span id="navMailBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-black bg-sky-500 text-white flex items-center justify-center leading-none ring-2 ring-white dark:ring-[#0c0f17] shadow-xs pointer-events-none"></span>
|
||||
</a>
|
||||
|
||||
<!-- Dropdown: Projecten (Bord, Tijdlijn, Brainstorm, Roadmap) -->
|
||||
<?php
|
||||
$isProjectsActive = in_array($currentPage, ['board.php', 'timeline.php', 'brainstorm.php', 'brainstorm_canvas.php', 'roadmap.php']);
|
||||
?>
|
||||
<div class="relative group/projects">
|
||||
<button type="button" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition cursor-pointer <?= $isProjectsActive ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="folder-kanban" class="w-4 h-4"></i>
|
||||
<span>Projecten</span>
|
||||
<i data-lucide="chevron-down" class="w-3.5 h-3.5 text-slate-400 transition-transform duration-200 group-hover/projects:rotate-180"></i>
|
||||
</button>
|
||||
<div class="absolute left-0 top-full pt-2 w-64 hidden group-hover/projects:block z-50 animate-in fade-in zoom-in-95 duration-150">
|
||||
<div class="bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-xl p-2 space-y-1 backdrop-blur-md">
|
||||
<!-- 1. Bord (Kanban) -->
|
||||
<a href="/board.php" class="flex items-start space-x-3 p-2.5 rounded-xl transition <?= $currentPage === 'board.php' ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/70' ?>">
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-100 dark:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="kanban" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold leading-tight">Bord</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 leading-snug">Lanes, post-its & Outlook planning</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 2. Tijdlijn (Gantt) -->
|
||||
<a href="/timeline.php" class="flex items-start space-x-3 p-2.5 rounded-xl transition <?= $currentPage === 'timeline.php' ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/70' ?>">
|
||||
<div class="w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-500/20 text-violet-600 dark:text-violet-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold leading-tight">Tijdlijn</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 leading-snug">Gantt-planning & projectkalender</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 3. Brainstorm -->
|
||||
<a href="/brainstorm.php" class="flex items-start space-x-3 p-2.5 rounded-xl transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/70' ?>">
|
||||
<div class="w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<a href="/brainstorm.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold leading-tight">Brainstorm</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 leading-snug">Ideeënsessies & canvas</div>
|
||||
</div>
|
||||
<span>Brainstorm</span>
|
||||
</a>
|
||||
|
||||
<!-- 3. Roadmap -->
|
||||
<a href="/roadmap.php" class="flex items-start space-x-3 p-2.5 rounded-xl transition <?= $currentPage === 'roadmap.php' ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/70' ?>">
|
||||
<div class="w-8 h-8 rounded-lg bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<a href="/roadmap.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'roadmap.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="map" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold leading-tight">Roadmap</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 leading-snug">Strategische planning & kwartalen</div>
|
||||
</div>
|
||||
<span>Roadmap</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/jira.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'jira.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="layers" class="w-4 h-4 text-sky-500"></i>
|
||||
<span>Jira</span>
|
||||
<a href="/integrations.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'integrations.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="layers" class="w-4 h-4"></i>
|
||||
<span>Integraties</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' ?>">
|
||||
<a href="/users.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'users.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 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>
|
||||
<span>Team & Invites</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Center: Unified Dashboard Toolbar Pill (Gecentreerd in de header) -->
|
||||
<?php if ($currentPage === 'planner.php' && !empty($formattedSelectedDate)): ?>
|
||||
<div class="flex items-center justify-center flex-1 mx-2 lg:mx-4 shrink-0">
|
||||
<div class="flex items-center bg-slate-100/80 dark:bg-slate-800/60 rounded-xl p-1 border border-slate-200/80 dark:border-slate-700/60 shadow-2xs whitespace-nowrap">
|
||||
<!-- Date & Status -->
|
||||
<div class="flex items-center space-x-2 px-2.5 py-1 text-xs text-slate-700 dark:text-slate-200 select-none">
|
||||
<div class="w-6 h-6 rounded-lg bg-indigo-50 dark:bg-indigo-500/15 text-indigo-600 dark:text-indigo-400 flex items-center justify-center shrink-0">
|
||||
<i data-lucide="calendar" class="w-3.5 h-3.5"></i>
|
||||
</div>
|
||||
<span id="headerCurrentDateDisplay" class="font-semibold tracking-tight whitespace-nowrap leading-none"><?= htmlspecialchars($formattedSelectedDate) ?></span>
|
||||
|
||||
<!-- Badges -->
|
||||
<span id="headerDateBadgesContainer" class="inline-flex items-center gap-1.5 ml-1">
|
||||
<?php if (!empty($isSelectedDateWorkday)): ?>
|
||||
<span class="hidden lg:inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 leading-none" title="Reguliere werkdag">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
<span>Werkdag</span>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 leading-none" title="Weekend / Vrije dag">
|
||||
<i data-lucide="coffee" class="w-2.5 h-2.5 text-amber-500"></i>
|
||||
<span>Vrij</span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($isSelectedDateBlocked)): ?>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30 cursor-pointer hover:bg-amber-500/25 transition leading-none" onclick="openCalendarBlockModal(<?= htmlspecialchars(json_encode($selectedDayBlock), ENT_QUOTES) ?>)" title="<?= htmlspecialchars($selectedDayBlock['title'] ?? '') ?> (klik om te bewerken)">
|
||||
<i data-lucide="palmtree" class="w-2.5 h-2.5 text-amber-600 dark:text-amber-400"></i>
|
||||
<span><?= htmlspecialchars($selectedDayBlock['title'] ?? '') ?></span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<!-- Right side: User & 2FA Status -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="hidden sm:flex items-center space-x-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/25 text-emerald-400 text-xs font-medium">
|
||||
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i>
|
||||
<span>2FA Actief</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700 mx-0.5 shrink-0 self-center"></div>
|
||||
<div class="text-right hidden sm:block">
|
||||
<div class="text-xs font-semibold text-slate-200"><?= htmlspecialchars($currentUser['name']) ?></div>
|
||||
<div class="text-[10px] text-slate-500">
|
||||
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?> • <?= htmlspecialchars($currentUser['email']) ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instellingen Tandwiel Dropdown -->
|
||||
<div class="relative flex items-center justify-center">
|
||||
<button id="plannerSettingsDropdownBtn" onclick="togglePlannerSettingsMenu(event)" class="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-white dark:hover:bg-slate-700/60 transition cursor-pointer" title="Dashboard Opties & Instellingen">
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div id="plannerSettingsDropdownMenu" class="hidden absolute right-0 top-full mt-2 w-56 rounded-2xl bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 shadow-xl py-1.5 z-50 text-xs text-slate-700 dark:text-slate-300">
|
||||
<button onclick="closePlannerSettingsMenu(); openWidgetManagerModal();" class="w-full text-left px-3.5 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/70 flex items-center space-x-2.5 transition cursor-pointer">
|
||||
<i data-lucide="layout-grid" class="w-4 h-4 text-indigo-500"></i>
|
||||
<span class="font-medium">Widgets & Indeling</span>
|
||||
</button>
|
||||
<button onclick="closePlannerSettingsMenu(); openCalendarBlockModal();" class="w-full text-left px-3.5 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/70 flex items-center space-x-2.5 transition cursor-pointer">
|
||||
<i data-lucide="palmtree" class="w-4 h-4 text-amber-500"></i>
|
||||
<span class="font-medium">Vakantie / Dagen Blokkeren</span>
|
||||
</button>
|
||||
<?php if (!empty($isOutlookConnected)): ?>
|
||||
<button onclick="closePlannerSettingsMenu(); triggerManualSync();" class="w-full text-left px-3.5 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/70 flex items-center space-x-2.5 transition cursor-pointer border-t border-slate-100 dark:border-slate-800/80">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4 text-sky-500"></i>
|
||||
<span class="font-medium">Outlook Synchroniseren</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Right side: Clean User Profile & Logout -->
|
||||
<div class="flex items-center space-x-2 sm:space-x-3 shrink-0">
|
||||
|
||||
<!-- Profiel link rechtsboven -->
|
||||
<a href="/profile.php" class="flex items-center space-x-2.5 group hover:opacity-90 transition px-2 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60 shrink-0" title="Mijn Profiel & Instellingen">
|
||||
<div class="w-7 h-7 rounded-lg bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 shadow-xs overflow-hidden">
|
||||
<?php if (!empty($currentUser['avatar_url'])): ?>
|
||||
<img src="<?= htmlspecialchars($currentUser['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-[6px] object-cover bg-white dark:bg-slate-950" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
|
||||
<div class="hidden w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="text-left hidden sm:block">
|
||||
<div class="text-xs font-semibold text-slate-800 dark:text-slate-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition leading-tight">
|
||||
<?= 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 shrink-0">
|
||||
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 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="layout-dashboard" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Fociz</span>
|
||||
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-800/60 text-xs">
|
||||
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
|
||||
<i data-lucide="calendar" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Planning</span>
|
||||
</a>
|
||||
<a href="/mail.php" class="relative flex flex-col items-center py-1 <?= $currentPage === 'mail.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<div class="relative">
|
||||
<i data-lucide="mail" class="w-4 h-4 mb-0.5"></i>
|
||||
<span id="navMailBadgeMobile" class="hidden absolute -top-1 -right-2 min-w-[14px] h-[14px] px-0.5 rounded-full text-[8px] font-black bg-sky-500 text-white flex items-center justify-center leading-none ring-2 ring-white dark:ring-[#0c0f17]"></span>
|
||||
</div>
|
||||
<span>Mail</span>
|
||||
</a>
|
||||
<a href="/board.php" class="flex flex-col items-center py-1 <?= $currentPage === 'board.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="kanban" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Bord</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' ?>">
|
||||
<a href="/brainstorm.php" class="flex flex-col items-center py-1 <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'text-emerald-400 font-bold' : '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' ?>">
|
||||
<a href="/roadmap.php" class="flex flex-col items-center py-1 <?= $currentPage === 'roadmap.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
|
||||
<i data-lucide="map" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Roadmap</span>
|
||||
</a>
|
||||
<a href="/jira.php" class="flex flex-col items-center py-1 <?= $currentPage === 'jira.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<a href="/integrations.php" class="flex flex-col items-center py-1 <?= $currentPage === 'integrations.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
|
||||
<i data-lucide="layers" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Jira</span>
|
||||
<span>Koppelingen</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 href="/users.php" class="flex flex-col items-center py-1 <?= $currentPage === 'users.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
|
||||
<i data-lucide="users" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Team</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');
|
||||
}
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
|
||||
|
||||
// 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>
|
||||
|
||||
<?php $isBoardPage = in_array($currentPage, ['board.php', 'timeline.php']); ?>
|
||||
<main id="focizMainContainer" class="flex-1 <?= $isBoardPage ? 'w-full max-w-none px-3 sm:px-6 py-4' : 'max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8' ?> transition-all duration-300">
|
||||
|
||||
@@ -70,8 +70,7 @@ if (!function_exists('fociz_http_request')) {
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(4, max(1, $timeout - 1)));
|
||||
curl_setopt($ch, CURLOPT_DNS_CACHE_TIMEOUT, 600);
|
||||
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);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<?php
|
||||
http_response_code(403);
|
||||
exit('Toegang geweigerd.');
|
||||
@@ -1,648 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Pure-PHP SMTP Mailer & Responsive HTML E-mail Engine
|
||||
*
|
||||
* Volledig zelfstandige, veilige SMTP-client zonder externe afhankelijkheden.
|
||||
* Ondersteunt STARTTLS (poort 587), SSL (poort 465), AUTH LOGIN/PLAIN en
|
||||
* levert responsive, hoogwaardig gestylede HTML e-mails in de Fociz-huisstijl.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
class FocizMailer {
|
||||
|
||||
/**
|
||||
* Verstuurt een e-mail via native PHP stream sockets over SMTP.
|
||||
*
|
||||
* @param string $toEmail Ontvanger e-mailadres
|
||||
* @param string $toName Ontvanger weergavenaam
|
||||
* @param string $subject Onderwerp van het bericht
|
||||
* @param string $htmlBody Opgemaakte HTML inhoud
|
||||
* @param string|null $plainTextBody Optionele platte tekst (wordt anders afgeleid uit HTML)
|
||||
* @param array $options Extra opties (from_email, from_name, reply_to, headers)
|
||||
* @return array ['success' => bool, 'error' => ?string, 'logs' => array]
|
||||
*/
|
||||
public static function send(
|
||||
string $toEmail,
|
||||
string $toName,
|
||||
string $subject,
|
||||
string $htmlBody,
|
||||
?string $plainTextBody = null,
|
||||
array $options = []
|
||||
): array {
|
||||
$logs = [];
|
||||
$log = function(string $msg) use (&$logs) {
|
||||
$logs[] = date('H:i:s') . ' ' . $msg;
|
||||
};
|
||||
|
||||
// Saniteer e-mailadressen en headers tegen CRLF en SMTP command injection
|
||||
$toEmail = str_replace(["\r", "\n", "\0"], '', trim($toEmail));
|
||||
$toName = str_replace(["\r", "\n", "\0"], '', trim($toName));
|
||||
$subject = str_replace(["\r", "\n", "\0"], '', trim($subject));
|
||||
|
||||
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
$err = "Ongeldig e-mailadres voor verzending: {$toEmail}";
|
||||
$log("FOUT: {$err}");
|
||||
return ['success' => false, 'error' => $err, 'logs' => $logs];
|
||||
}
|
||||
|
||||
// 1. Configuratieparameters ophalen
|
||||
$host = $options['host'] ?? (defined('SMTP_HOST') ? SMTP_HOST : 'mail.fociz.nl');
|
||||
$port = (int)($options['port'] ?? (defined('SMTP_PORT') ? SMTP_PORT : 587));
|
||||
$username = $options['username'] ?? (defined('SMTP_USER') ? SMTP_USER : 'info@fociz.nl');
|
||||
$password = $options['password'] ?? (defined('SMTP_PASS') ? SMTP_PASS : '');
|
||||
$secure = strtolower($options['secure'] ?? (defined('SMTP_SECURE') ? SMTP_SECURE : 'tls'));
|
||||
$fromEmail = str_replace(["\r", "\n", "\0"], '', trim($options['from_email'] ?? (defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl')));
|
||||
$fromName = str_replace(["\r", "\n", "\0"], '', trim($options['from_name'] ?? (defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Fociz')));
|
||||
$replyTo = str_replace(["\r", "\n", "\0"], '', trim($options['reply_to'] ?? (defined('SMTP_REPLY_TO') ? SMTP_REPLY_TO : 'info@fociz.nl')));
|
||||
$timeout = (int)($options['timeout'] ?? 15);
|
||||
|
||||
// Technische afzender voor de SMTP envelope (moet doorgaans overeenkomen met het geauthenticeerde account)
|
||||
$envelopeFrom = !empty($username) && filter_var($username, FILTER_VALIDATE_EMAIL) ? $username : $fromEmail;
|
||||
|
||||
if (empty($plainTextBody)) {
|
||||
$plainTextBody = self::htmlToPlainText($htmlBody);
|
||||
}
|
||||
|
||||
$log("Verbinding initialiseren naar {$host}:{$port} (beveiliging: {$secure})...");
|
||||
|
||||
// 2. Socket verbinden
|
||||
$socketContext = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true,
|
||||
]
|
||||
]);
|
||||
|
||||
$remoteTarget = ($secure === 'ssl' || $port === 465) ? "ssl://{$host}:{$port}" : "tcp://{$host}:{$port}";
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
|
||||
$socket = @stream_socket_client(
|
||||
$remoteTarget,
|
||||
$errno,
|
||||
$errstr,
|
||||
$timeout,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$socketContext
|
||||
);
|
||||
|
||||
if (!$socket) {
|
||||
$err = "Kan geen socketverbinding openen naar {$host}:{$port} [{$errno}]: {$errstr}";
|
||||
$log("FOUT: {$err}");
|
||||
error_log("FocizMailer: " . $err);
|
||||
return ['success' => false, 'error' => $err, 'logs' => $logs];
|
||||
}
|
||||
|
||||
stream_set_timeout($socket, $timeout);
|
||||
|
||||
// Helperfuncties voor SMTP commando's
|
||||
$readResponse = function() use ($socket, $log): array {
|
||||
$data = '';
|
||||
$code = 0;
|
||||
while (!feof($socket)) {
|
||||
$line = fgets($socket, 1024);
|
||||
if ($line === false) break;
|
||||
$data .= $line;
|
||||
if (preg_match('/^([0-9]{3})([ -])(.*)$/', $line, $matches)) {
|
||||
$code = (int)$matches[1];
|
||||
if ($matches[2] === ' ') {
|
||||
// Laatste regel van het antwoord
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$log("<< " . trim(preg_replace('/\r?\n/', ' | ', $data)));
|
||||
return ['code' => $code, 'text' => $data];
|
||||
};
|
||||
|
||||
$sendCommand = function(string $cmd, string $maskLog = '') use ($socket, $log, $readResponse): array {
|
||||
$logMsg = !empty($maskLog) ? $maskLog : $cmd;
|
||||
$log(">> " . trim($logMsg));
|
||||
fwrite($socket, $cmd . "\r\n");
|
||||
return $readResponse();
|
||||
};
|
||||
|
||||
try {
|
||||
// 3. Welkomstbanner lezen (220)
|
||||
$resp = $readResponse();
|
||||
if ($resp['code'] !== 220) {
|
||||
throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}");
|
||||
}
|
||||
|
||||
// 4. EHLO Handshake conform RFC 5321 §4.1.4
|
||||
// Voorkom impersonation van het lokale domein (bijv. 'fociz.nl' tegen 'mail.fociz.nl')
|
||||
// Antagonist/Exim weigert 'EHLO fociz.nl' van externe hosts met '550 Bad HELO - Host impersonating domain name'
|
||||
$resolveHeloHost = function() use ($host): string {
|
||||
// 1. Mailserver hostnaam indien beschikbaar en niet een lokaal IP
|
||||
if (!empty($host) && str_contains($host, '.') && !str_starts_with($host, '192.168.') && !str_starts_with($host, '127.')) {
|
||||
return $host;
|
||||
}
|
||||
// 2. SERVER_NAME mits het niet het bare hoofddomein is
|
||||
if (!empty($_SERVER['SERVER_NAME'])) {
|
||||
$sn = preg_replace('/:[0-9]+$/', '', trim($_SERVER['SERVER_NAME']));
|
||||
if (!in_array(strtolower($sn), ['fociz.nl', 'localhost', '127.0.0.1'])) {
|
||||
return $sn;
|
||||
}
|
||||
}
|
||||
// 3. Systeemhostnaam
|
||||
if (function_exists('gethostname')) {
|
||||
$gh = gethostname();
|
||||
if (!empty($gh) && strtolower($gh) !== 'fociz.nl') {
|
||||
return $gh;
|
||||
}
|
||||
}
|
||||
return 'mail.fociz.nl';
|
||||
};
|
||||
|
||||
$performHeloHandshake = function(string $preferredHost) use ($sendCommand, $log): string {
|
||||
$candidates = array_unique([$preferredHost, 'mail.fociz.nl', 'localhost', '127.0.0.1']);
|
||||
$lastResp = null;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$resp = $sendCommand("EHLO {$candidate}");
|
||||
if ($resp['code'] === 250) {
|
||||
return $candidate;
|
||||
}
|
||||
$lastResp = $resp;
|
||||
$log("Waarschuwing: EHLO {$candidate} niet geaccepteerd (code {$resp['code']}). Probeert alternatief...");
|
||||
}
|
||||
|
||||
// Fallback naar HELO indien EHLO op alle kandidaten niet werkt
|
||||
foreach ($candidates as $candidate) {
|
||||
$resp = $sendCommand("HELO {$candidate}");
|
||||
if ($resp['code'] === 250) {
|
||||
return $candidate;
|
||||
}
|
||||
$lastResp = $resp;
|
||||
}
|
||||
|
||||
throw new Exception("EHLO/HELO geweigerd door server voor alle kandidaten. Laatste reactie: " . ($lastResp ? "({$lastResp['code']}) {$lastResp['text']}" : 'onbekend'));
|
||||
};
|
||||
|
||||
$preferredHost = $resolveHeloHost();
|
||||
$clientHost = $performHeloHandshake($preferredHost);
|
||||
|
||||
// 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls')
|
||||
if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) {
|
||||
$log("STARTTLS handshake starten...");
|
||||
$resp = $sendCommand("STARTTLS");
|
||||
if ($resp['code'] !== 220) {
|
||||
throw new Exception("STARTTLS geweigerd door server (code {$resp['code']}): {$resp['text']}");
|
||||
}
|
||||
|
||||
// Crypto activeren op de actieve stream
|
||||
$cryptoMethod = STREAM_CRYPTO_METHOD_TLS_CLIENT;
|
||||
if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) {
|
||||
$cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
|
||||
}
|
||||
if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) {
|
||||
$cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
|
||||
}
|
||||
|
||||
$cryptoSuccess = @stream_socket_enable_crypto($socket, true, $cryptoMethod);
|
||||
if (!$cryptoSuccess) {
|
||||
throw new Exception("TLS encryptiehandshake kon niet tot stand worden gebracht.");
|
||||
}
|
||||
$log("TLS encryptie succesvol geactiveerd.");
|
||||
|
||||
// Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207)
|
||||
$clientHost = $performHeloHandshake($clientHost);
|
||||
}
|
||||
|
||||
// 6. SMTP Authenticatie
|
||||
if (!empty($username) && !empty($password)) {
|
||||
$log("Authenticatie uitvoeren als '{$username}'...");
|
||||
$resp = $sendCommand("AUTH LOGIN");
|
||||
if ($resp['code'] === 334) {
|
||||
// Stuur gebruikersnaam
|
||||
$resp = $sendCommand(base64_encode($username), "AUTH USER [base64]");
|
||||
if ($resp['code'] !== 334) {
|
||||
throw new Exception("Gebruikersnaam geweigerd voor authenticatie: {$resp['text']}");
|
||||
}
|
||||
// Stuur wachtwoord
|
||||
$resp = $sendCommand(base64_encode($password), "AUTH PASS [base64 masked]");
|
||||
if ($resp['code'] !== 235) {
|
||||
throw new Exception("Wachtwoord onjuist of authenticatie mislukt (code {$resp['code']}): {$resp['text']}");
|
||||
}
|
||||
} else {
|
||||
// Fallback naar AUTH PLAIN
|
||||
$plainToken = base64_encode("\0" . $username . "\0" . $password);
|
||||
$resp = $sendCommand("AUTH PLAIN {$plainToken}", "AUTH PLAIN [token masked]");
|
||||
if ($resp['code'] !== 235) {
|
||||
throw new Exception("SMTP authenticatie geweigerd (code {$resp['code']}): {$resp['text']}");
|
||||
}
|
||||
}
|
||||
$log("SMTP Authenticatie geslaagd.");
|
||||
}
|
||||
|
||||
// 7. Afzender en Ontvanger instellen
|
||||
$resp = $sendCommand("MAIL FROM:<{$envelopeFrom}>");
|
||||
if ($resp['code'] !== 250) {
|
||||
// Probeer met fromEmail indien envelopeFrom faalt
|
||||
if ($envelopeFrom !== $fromEmail) {
|
||||
$resp = $sendCommand("MAIL FROM:<{$fromEmail}>");
|
||||
}
|
||||
if ($resp['code'] !== 250) {
|
||||
throw new Exception("MAIL FROM geweigerd (code {$resp['code']}): {$resp['text']}");
|
||||
}
|
||||
}
|
||||
|
||||
$resp = $sendCommand("RCPT TO:<{$toEmail}>");
|
||||
if ($resp['code'] !== 250 && $resp['code'] !== 251) {
|
||||
throw new Exception("RCPT TO geweigerd voor {$toEmail} (code {$resp['code']}): {$resp['text']}");
|
||||
}
|
||||
|
||||
// 8. DATA Commando
|
||||
$resp = $sendCommand("DATA");
|
||||
if ($resp['code'] !== 354) {
|
||||
throw new Exception("Server niet gereed voor e-maildata (code {$resp['code']}): {$resp['text']}");
|
||||
}
|
||||
|
||||
// 9. MIME Multipart/Alternative Samenstellen
|
||||
$boundary = "----=_Part_Fociz_" . md5(uniqid((string)time(), true));
|
||||
$encodedSubject = "=?UTF-8?B?" . base64_encode($subject) . "?=";
|
||||
$encodedFromName = "=?UTF-8?B?" . base64_encode($fromName) . "?=";
|
||||
$encodedToName = !empty($toName) ? "=?UTF-8?B?" . base64_encode($toName) . "?= " : "";
|
||||
|
||||
$headers = [];
|
||||
$headers[] = "Date: " . date(DATE_RFC2822);
|
||||
$headers[] = "From: {$encodedFromName} <{$fromEmail}>";
|
||||
$headers[] = "To: {$encodedToName}<{$toEmail}>";
|
||||
if (!empty($replyTo)) {
|
||||
$headers[] = "Reply-To: <{$replyTo}>";
|
||||
}
|
||||
$headers[] = "Subject: {$encodedSubject}";
|
||||
$headers[] = "Message-ID: <" . time() . "." . bin2hex(random_bytes(8)) . "@" . ($clientHost ?: 'fociz.nl') . ">";
|
||||
$headers[] = "X-Mailer: Fociz Mail Engine 2026";
|
||||
$headers[] = "MIME-Version: 1.0";
|
||||
$headers[] = "Content-Type: multipart/alternative; boundary=\"{$boundary}\"";
|
||||
|
||||
$rawEmail = implode("\r\n", $headers) . "\r\n\r\n";
|
||||
|
||||
// Deel 1: Platte tekst fallback
|
||||
$rawEmail .= "--{$boundary}\r\n";
|
||||
$rawEmail .= "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n";
|
||||
$rawEmail .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||
$rawEmail .= $plainTextBody . "\r\n\r\n";
|
||||
|
||||
// Deel 2: Luxe HTML variant
|
||||
$rawEmail .= "--{$boundary}\r\n";
|
||||
$rawEmail .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
$rawEmail .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||
$rawEmail .= $htmlBody . "\r\n\r\n";
|
||||
|
||||
// Sluiting boundary
|
||||
$rawEmail .= "--{$boundary}--\r\n";
|
||||
|
||||
// Zorg voor veilige dot-stuffing (regels die beginnen met een punt krijgen een extra punt)
|
||||
$safeLines = [];
|
||||
foreach (explode("\n", str_replace("\r\n", "\n", $rawEmail)) as $line) {
|
||||
if (isset($line[0]) && $line[0] === '.') {
|
||||
$line = '.' . $line;
|
||||
}
|
||||
$safeLines[] = $line;
|
||||
}
|
||||
$finalPayload = implode("\r\n", $safeLines) . "\r\n.\r\n";
|
||||
|
||||
// Schrijf e-mail inhoud
|
||||
$log("E-maildata overdragen (" . strlen($finalPayload) . " bytes)...");
|
||||
fwrite($socket, $finalPayload);
|
||||
$resp = $readResponse();
|
||||
|
||||
if ($resp['code'] !== 250) {
|
||||
throw new Exception("Fout bij accepteren van e-maildata door de server (code {$resp['code']}): {$resp['text']}");
|
||||
}
|
||||
|
||||
$log("E-mail succesvol geaccepteerd voor aflevering door {$host}.");
|
||||
|
||||
// 10. QUIT Commando
|
||||
$sendCommand("QUIT");
|
||||
@fclose($socket);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'error' => null,
|
||||
'logs' => $logs
|
||||
];
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$log("FOUT: " . $e->getMessage());
|
||||
error_log("FocizMailer Fout: " . $e->getMessage());
|
||||
@fwrite($socket, "QUIT\r\n");
|
||||
@fclose($socket);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'logs' => $logs
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een hoogwaardig gestylede, responsive HTML e-mail template
|
||||
* in de kenmerkende moderne Fociz donker/licht huisstijl.
|
||||
*/
|
||||
public static function renderEmailTemplate(array $params): string {
|
||||
$preheader = htmlspecialchars($params['preheader'] ?? 'Bericht vanuit Fociz');
|
||||
$badge = htmlspecialchars($params['badge'] ?? 'FOCIZ SYSTEEM');
|
||||
$title = htmlspecialchars($params['title'] ?? 'Belangrijk bericht van Fociz');
|
||||
$contentHtml = $params['content_html'] ?? '';
|
||||
$ctaText = !empty($params['cta_text']) ? htmlspecialchars($params['cta_text']) : null;
|
||||
$ctaUrl = !empty($params['cta_url']) ? htmlspecialchars($params['cta_url']) : null;
|
||||
$ctaSubtext = !empty($params['cta_subtext']) ? htmlspecialchars($params['cta_subtext']) : null;
|
||||
$securityNotice = $params['security_notice'] ?? 'Heeft u dit verzoek niet zelf ingediend? Dan kunt u deze e-mail veilig negeren. Uw gegevens blijven optimaal beschermd.';
|
||||
$currentYear = date('Y');
|
||||
$appUrl = defined('APP_URL') ? APP_URL : 'https://www.fociz.nl';
|
||||
|
||||
$ctaBlock = '';
|
||||
if ($ctaText && $ctaUrl) {
|
||||
$ctaBlock = "
|
||||
<!-- Call To Action Knop -->
|
||||
<table role='presentation' border='0' cellpadding='0' cellspacing='0' style='margin: 32px 0 24px 0;'>
|
||||
<tr>
|
||||
<td align='center' style='border-radius: 12px; background: #4f46e5;'>
|
||||
<a href='{$ctaUrl}' target='_blank' style='display: inline-block; padding: 14px 32px; font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 700; color: #ffffff; text-decoration: none; border-radius: 12px; background: #4f46e5; letter-spacing: 0.2px;'>
|
||||
{$ctaText} →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
" . ($ctaSubtext ? "<p style='margin: 0 0 20px 0; font-size: 12px; color: #64748b; line-height: 1.5;'>{$ctaSubtext}</p>" : "") . "
|
||||
<p style='margin: 16px 0 0 0; font-size: 11px; color: #94a3b8; word-break: break-all; line-height: 1.5;'>
|
||||
Werkt de knop niet? Kopieer en plak dan deze link in uw browser:<br>
|
||||
<a href='{$ctaUrl}' style='color: #6366f1; text-decoration: underline;'>{$ctaUrl}</a>
|
||||
</p>
|
||||
";
|
||||
}
|
||||
|
||||
return "<!DOCTYPE html>
|
||||
<html lang='nl'>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>{$title}</title>
|
||||
<!--[if mso]>
|
||||
<style type='text/css'>
|
||||
body, table, td, a { font-family: Arial, sans-serif !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style>
|
||||
body { margin: 0; padding: 0; min-width: 100%; background-color: #0c0f17; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
|
||||
img { border: 0; outline: none; text-decoration: none; }
|
||||
table { border-collapse: collapse; }
|
||||
td { border-collapse: collapse; }
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container { width: 100% !important; padding: 12px !important; }
|
||||
.card-content { padding: 24px 20px !important; }
|
||||
.header-banner { padding: 28px 20px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style='margin: 0; padding: 0; background-color: #0c0f17; -webkit-font-smoothing: antialiased;'>
|
||||
<!-- Verborgen Pre-header tekst voor inbox preview -->
|
||||
<div style='display: none; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: #0c0f17; opacity: 0;'>
|
||||
{$preheader} ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
|
||||
</div>
|
||||
|
||||
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='background-color: #0c0f17;'>
|
||||
<tr>
|
||||
<td align='center' style='padding: 40px 16px 50px 16px;'>
|
||||
<!-- Hoofdcontainer -->
|
||||
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='max-width: 580px;' class='email-container'>
|
||||
|
||||
<!-- Luxe Bovenkaart -->
|
||||
<tr>
|
||||
<td style='background: #111622; border-top-left-radius: 20px; border-top-right-radius: 20px; border: 1px solid #1e293b; border-bottom: none; padding: 36px 36px 32px 36px;' class='header-banner'>
|
||||
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Logo & Titel -->
|
||||
<table role='presentation' border='0' cellpadding='0' cellspacing='0'>
|
||||
<tr>
|
||||
<td style='width: 44px; height: 44px; background: linear-gradient(135deg, #4f46e5 0%, #312e81 100%); border-radius: 12px; text-align: center; vertical-align: middle; border: 1px solid #6366f1; box-shadow: 0 4px 12px rgba(79, 70, 229, 0.35);'>
|
||||
<span style='font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif; font-size: 22px; font-weight: 900; color: #ffffff; line-height: 44px; display: inline-block;'>F</span>
|
||||
</td>
|
||||
<td style='padding-left: 14px;'>
|
||||
<span style='font-size: 22px; font-weight: 900; color: #ffffff; letter-spacing: -0.5px; display: block;'>Fociz</span>
|
||||
<span style='font-size: 11px; font-weight: 500; color: #94a3b8; letter-spacing: 0.5px; text-transform: uppercase;'>Focus op wat telt</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td align='right' style='vertical-align: top;'>
|
||||
<!-- Badge -->
|
||||
<span style='display: inline-block; padding: 5px 12px; font-size: 10px; font-weight: 700; color: #818cf8; background: rgba(99, 102, 241, 0.12); border: 1px solid rgba(99, 102, 241, 0.28); border-radius: 20px; letter-spacing: 0.6px; text-transform: uppercase;'>
|
||||
{$badge}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Hoofdkaart met Inhoud -->
|
||||
<tr>
|
||||
<td style='background: #151d2f; border-bottom-left-radius: 20px; border-bottom-right-radius: 20px; border: 1px solid #1e293b; border-top: 1px solid #243048; padding: 36px;' class='card-content'>
|
||||
|
||||
<h1 style='margin: 0 0 16px 0; font-size: 22px; font-weight: 800; color: #ffffff; letter-spacing: -0.4px; line-height: 1.3;'>
|
||||
{$title}
|
||||
</h1>
|
||||
|
||||
<div style='font-size: 14px; line-height: 1.7; color: #cbd5e1;'>
|
||||
{$contentHtml}
|
||||
</div>
|
||||
|
||||
{$ctaBlock}
|
||||
|
||||
<!-- Beveiligings- & Waarschuwingsbox -->
|
||||
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='margin-top: 32px; border-top: 1px solid #1e293b; padding-top: 20px;'>
|
||||
<tr>
|
||||
<td style='background: rgba(15, 23, 42, 0.6); border: 1px solid #243048; border-radius: 12px; padding: 14px 16px;'>
|
||||
<table role='presentation' border='0' cellpadding='0' cellspacing='0'>
|
||||
<tr>
|
||||
<td style='vertical-align: top; padding-right: 10px; font-size: 16px;'>
|
||||
🔒
|
||||
</td>
|
||||
<td style='font-size: 12px; line-height: 1.5; color: #94a3b8;'>
|
||||
{$securityNotice}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td align='center' style='padding: 28px 20px 0 20px;'>
|
||||
<p style='margin: 0 0 6px 0; font-size: 12px; color: #64748b; font-weight: 500;'>
|
||||
© {$currentYear} Fociz • Veilige Project- & Dagplanning
|
||||
</p>
|
||||
<p style='margin: 0; font-size: 11px; color: #475569;'>
|
||||
Dit is een geautomatiseerd systeembericht verzonden naar u door Fociz.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verstuurt een 'Wachtwoord Vergeten' herstelmail
|
||||
*/
|
||||
public static function sendPasswordResetEmail(string $email, string $name, string $resetUrl): array {
|
||||
$cleanName = !empty($name) ? htmlspecialchars($name) : 'Collega';
|
||||
$title = "Herstel uw Fociz wachtwoord";
|
||||
$preheader = "U heeft een verzoek ingediend om uw Fociz wachtwoord opnieuw in te stellen.";
|
||||
|
||||
$contentHtml = "
|
||||
<p style='margin: 0 0 14px 0;'>Beste <strong>{$cleanName}</strong>,</p>
|
||||
<p style='margin: 0 0 14px 0;'>
|
||||
Er is zojuist een verzoek gedaan om het wachtwoord van uw Fociz-account opnieuw in te stellen voor het e-mailadres <strong style='color: #e2e8f0;'>{$email}</strong>.
|
||||
</p>
|
||||
<p style='margin: 0 0 14px 0;'>
|
||||
Klik op onderstaande knop om direct een nieuw wachtwoord te kiezen. Deze link is uit veiligheidsoverwegingen <strong>60 minuten geldig</strong>.
|
||||
</p>
|
||||
";
|
||||
|
||||
$html = self::renderEmailTemplate([
|
||||
'title' => $title,
|
||||
'preheader' => $preheader,
|
||||
'badge' => 'VEILIGHEID',
|
||||
'content_html' => $contentHtml,
|
||||
'cta_text' => 'Wachtwoord Opnieuw Instellen',
|
||||
'cta_url' => $resetUrl,
|
||||
'cta_subtext' => '⏱️ Deze herstellink verloopt over 60 minuten en kan slechts éénmaal gebruikt worden.',
|
||||
'security_notice' => 'Heeft u dit verzoek niet zelf gedaan? Geen zorgen, uw wachtwoord blijft ongewijzigd en uw account is veilig. U kunt deze e-mail gerust negeren.'
|
||||
]);
|
||||
|
||||
return self::send($email, $name, "Herstel uw Fociz wachtwoord", $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verstuurt een team-uitnodiging naar een nieuwe collega
|
||||
*/
|
||||
public static function sendInvitationEmail(
|
||||
string $email,
|
||||
string $inviterName,
|
||||
string $role,
|
||||
string $inviteUrl
|
||||
): array {
|
||||
$cleanInviter = !empty($inviterName) ? htmlspecialchars($inviterName) : 'Een beheerder';
|
||||
$roleLabel = ($role === 'ADMIN') ? 'Beheerder (Admin)' : 'Teamlid (Member)';
|
||||
$title = "U bent uitgenodigd voor Fociz";
|
||||
$preheader = "{$cleanInviter} heeft u uitgenodigd om samen te werken in Fociz.";
|
||||
|
||||
$contentHtml = "
|
||||
<p style='margin: 0 0 14px 0;'>Hallo,</p>
|
||||
<p style='margin: 0 0 14px 0;'>
|
||||
<strong>{$cleanInviter}</strong> heeft u uitgenodigd om toegang te krijgen tot de project- en dagplanner van <strong>Fociz</strong>.
|
||||
</p>
|
||||
<div style='background: rgba(99, 102, 241, 0.08); border: 1px solid rgba(99, 102, 241, 0.25); border-radius: 12px; padding: 14px 18px; margin: 18px 0;'>
|
||||
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
||||
<tr>
|
||||
<td style='font-size: 13px; color: #94a3b8;'>E-mailadres:</td>
|
||||
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0;'>{$email}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Toegewezen rol:</td>
|
||||
<td align='right' style='font-size: 13px; font-weight: 700; color: #818cf8; padding-top: 6px;'>{$roleLabel}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<p style='margin: 0 0 14px 0;'>
|
||||
Klik op onderstaande knop om uw account te activeren, uw wachtwoord in te stellen en de verplichte tweestapsverificatie (2FA) te koppelen.
|
||||
</p>
|
||||
";
|
||||
|
||||
$html = self::renderEmailTemplate([
|
||||
'title' => $title,
|
||||
'preheader' => $preheader,
|
||||
'badge' => 'TEAM UITNODIGING',
|
||||
'content_html' => $contentHtml,
|
||||
'cta_text' => 'Account Activeren & Inloggen',
|
||||
'cta_url' => $inviteUrl,
|
||||
'cta_subtext' => '⏱️ Deze uitnodigingslink is 7 dagen geldig.',
|
||||
'security_notice' => 'Fociz maakt gebruik van tweestapsverificatie (2FA). Houd een authenticator app zoals Google Authenticator of Microsoft Authenticator bij de hand.'
|
||||
]);
|
||||
|
||||
return self::send($email, '', "Uitnodiging voor Fociz door {$cleanInviter}", $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verstuurt een test e-mail ter verificatie van de SMTP verbinding
|
||||
*/
|
||||
public static function sendTestEmail(string $email, ?string $testerName = null): array {
|
||||
$cleanName = !empty($testerName) ? htmlspecialchars($testerName) : 'Beheerder';
|
||||
$title = "Fociz Mail Server Test Succesvol!";
|
||||
$preheader = "De SMTP mailserver koppeling met mail.fociz.nl functioneert naar behoren.";
|
||||
$smtpHost = defined('SMTP_HOST') ? SMTP_HOST : 'mail.fociz.nl';
|
||||
$smtpPort = defined('SMTP_PORT') ? SMTP_PORT : 587;
|
||||
$fromEmail = defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl';
|
||||
$currentTime = date('d-m-Y H:i:s');
|
||||
|
||||
$contentHtml = "
|
||||
<p style='margin: 0 0 14px 0;'>Beste <strong>{$cleanName}</strong>,</p>
|
||||
<p style='margin: 0 0 14px 0;'>
|
||||
Gefeliciteerd! Als u dit bericht leest, is de verbinding met de Fociz SMTP mailserver volledig geslaagd en operationeel.
|
||||
</p>
|
||||
<div style='background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.25); border-radius: 12px; padding: 14px 18px; margin: 18px 0;'>
|
||||
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
||||
<tr>
|
||||
<td style='font-size: 13px; color: #94a3b8;'>SMTP Server:</td>
|
||||
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0;'>{$smtpHost}:{$smtpPort}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Verzendadres:</td>
|
||||
<td align='right' style='font-size: 13px; font-weight: 700; color: #10b981; padding-top: 6px;'>{$fromEmail}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Tijdstip:</td>
|
||||
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0; padding-top: 6px;'>{$currentTime}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<p style='margin: 0;'>
|
||||
Wachtwoord-vergeten e-mails, teamuitnodigingen en notificaties kunnen nu veilig en betrouwbaar worden verzonden.
|
||||
</p>
|
||||
";
|
||||
|
||||
$html = self::renderEmailTemplate([
|
||||
'title' => $title,
|
||||
'preheader' => $preheader,
|
||||
'badge' => 'VERIFICATIE TEST',
|
||||
'content_html' => $contentHtml,
|
||||
'cta_text' => 'Naar Fociz Dashboard',
|
||||
'cta_url' => (defined('APP_URL') ? APP_URL : 'https://www.fociz.nl') . '/admin.php',
|
||||
'security_notice' => 'Dit is een diagnostische testmail verzonden vanuit het Fociz Beheerpaneel.'
|
||||
]);
|
||||
|
||||
return self::send($email, $testerName ?? '', "Fociz Mail Server Test Succesvol ({$currentTime})", $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eenvoudige converter van HTML naar schone platte tekst voor e-mail fallbacks
|
||||
*/
|
||||
private static function htmlToPlainText(string $html): string {
|
||||
$text = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $html);
|
||||
$text = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $text);
|
||||
$text = strip_tags($text, '<a><p><br><h1><h2><h3><li>');
|
||||
$text = preg_replace('/<br\s*\/?>/i', "\n", $text);
|
||||
$text = preg_replace('/<\/(p|h1|h2|h3|li)>/i', "\n\n", $text);
|
||||
$text = strip_tags($text);
|
||||
$text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
|
||||
return trim(preg_replace("/[\r\n]{3,}/", "\n\n", $text));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
<?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 alle systeeminstellingen in 1 geoptimaliseerde query op en bewaart deze in het static runtime geheugen
|
||||
*/
|
||||
function get_all_system_settings(bool $forceRefresh = false): array {
|
||||
static $settingsCache = null;
|
||||
if ($settingsCache !== null && !$forceRefresh) {
|
||||
return $settingsCache;
|
||||
}
|
||||
|
||||
$settingsCache = [];
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_system_settings_table($db);
|
||||
|
||||
$stmt = $db->query("SELECT setting_key, setting_value FROM system_settings");
|
||||
while ($row = $stmt->fetch()) {
|
||||
$val = $row['setting_value'];
|
||||
if ($val === null) {
|
||||
$settingsCache[$row['setting_key']] = null;
|
||||
continue;
|
||||
}
|
||||
$trimmed = trim((string)$val);
|
||||
if (str_starts_with($trimmed, '{') || str_starts_with($trimmed, '[')) {
|
||||
$decoded = json_decode($val, true);
|
||||
$settingsCache[$row['setting_key']] = (json_last_error() === JSON_ERROR_NONE) ? $decoded : $val;
|
||||
} else {
|
||||
$settingsCache[$row['setting_key']] = $val;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("get_all_system_settings error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $settingsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt een enkele instelling op uit het geheugen (of fallback default)
|
||||
*/
|
||||
function get_system_setting(string $key, $default = null) {
|
||||
$all = get_all_system_settings();
|
||||
if (array_key_exists($key, $all) && $all[$key] !== null && $all[$key] !== '') {
|
||||
return $all[$key];
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slaat een instelling op in MariaDB en ververst direct het cachegeheugen
|
||||
*/
|
||||
function set_system_setting(string $key, $value): bool {
|
||||
try {
|
||||
$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()
|
||||
");
|
||||
$ok = $stmt->execute([$key, $valToStore]);
|
||||
if ($ok) {
|
||||
get_all_system_settings(true);
|
||||
}
|
||||
return $ok;
|
||||
} catch (Throwable $e) {
|
||||
error_log("set_system_setting error for {$key}: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt alle organisatie werkdag standaarden op (Release v1.4)
|
||||
*/
|
||||
function get_workday_settings(): array {
|
||||
$rawDays = get_system_setting('workdays', '1,2,3,4,5');
|
||||
$workdays = array_values(array_filter(array_map('intval', explode(',', $rawDays)), fn($d) => $d >= 1 && $d <= 7));
|
||||
if (empty($workdays)) {
|
||||
$workdays = [1, 2, 3, 4, 5];
|
||||
}
|
||||
return [
|
||||
'workday_start' => get_system_setting('workday_start', '08:30'),
|
||||
'workday_end' => get_system_setting('workday_end', '17:00'),
|
||||
'lunch_start' => get_system_setting('lunch_start', '12:30'),
|
||||
'lunch_end' => get_system_setting('lunch_end', '13:00'),
|
||||
'pomodoro_focus' => (int)get_system_setting('pomodoro_focus', 25),
|
||||
'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50),
|
||||
'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5),
|
||||
'default_task_duration' => (int)get_system_setting('default_task_duration', 30),
|
||||
'workdays' => $workdays,
|
||||
'workdays_str' => implode(',', $workdays),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt AI configuratie-instellingen op (Release v2.0 - Google Gemini Pro & OpenAI)
|
||||
* Ondersteunt organisatiebrede en optioneel persoonlijke sleutels.
|
||||
*/
|
||||
function get_ai_settings(?int $userId = null): array {
|
||||
if (!function_exists('fociz_decrypt')) {
|
||||
require_once __DIR__ . '/auth.php';
|
||||
}
|
||||
|
||||
$apiKey = '';
|
||||
$isPersonal = false;
|
||||
$personalProvider = null;
|
||||
$personalModel = null;
|
||||
|
||||
// 1. Controleer persoonlijke sleutel van de ingelogde gebruiker
|
||||
if ($userId !== null && $userId > 0) {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("SELECT access_token, settings, is_enabled FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') AND is_enabled = 1 ORDER BY id DESC LIMIT 1");
|
||||
$stmt->execute([$userId]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row) {
|
||||
// Sleutel kan in access_token staan of in settings['api_key']
|
||||
if (!empty($row['access_token'])) {
|
||||
$apiKey = fociz_decrypt((string)$row['access_token']);
|
||||
$isPersonal = true;
|
||||
} elseif (!empty($row['settings'])) {
|
||||
$userSettings = json_decode($row['settings'], true) ?: [];
|
||||
if (!empty($userSettings['api_key'])) {
|
||||
$apiKey = fociz_decrypt((string)$userSettings['api_key']);
|
||||
$isPersonal = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($row['settings'])) {
|
||||
$userSettings = json_decode($row['settings'], true) ?: [];
|
||||
if (!empty($userSettings['provider'])) {
|
||||
$personalProvider = (string)$userSettings['provider'];
|
||||
}
|
||||
if (!empty($userSettings['model'])) {
|
||||
$personalModel = (string)$userSettings['model'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen persoonlijke AI sleutel: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback naar organisatiebrede instellingen
|
||||
if (empty($apiKey)) {
|
||||
$savedKeyRaw = get_system_setting('ai_api_key', '');
|
||||
if (!empty($savedKeyRaw)) {
|
||||
$apiKey = fociz_decrypt((string)$savedKeyRaw);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback naar .env variabelen (GEMINI_API_KEY of OPENAI_API_KEY)
|
||||
if (empty($apiKey)) {
|
||||
$apiKey = (string)(fociz_env('GEMINI_API_KEY', '') ?: fociz_env('OPENAI_API_KEY', ''));
|
||||
}
|
||||
|
||||
$provider = !empty($personalProvider) ? $personalProvider : get_system_setting('ai_provider', 'gemini');
|
||||
if (empty($provider)) {
|
||||
$provider = 'gemini';
|
||||
}
|
||||
|
||||
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-2.0-flash');
|
||||
if (empty($model)) {
|
||||
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-2.0-flash';
|
||||
}
|
||||
|
||||
$apiKey = trim((string)$apiKey);
|
||||
$hasKey = ($apiKey !== '');
|
||||
|
||||
return [
|
||||
'ai_enabled' => (bool)get_system_setting('ai_enabled', $hasKey),
|
||||
'ai_provider' => (string)$provider,
|
||||
'ai_api_key' => $apiKey,
|
||||
'ai_model' => (string)$model,
|
||||
'has_key' => $hasKey,
|
||||
'is_personal_key'=> $isPersonal,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt geconfigureerde sessie idle timeout in seconden op conform NIST SP 800-63B / ISO 27001
|
||||
* Toegestane waarden: 900 (15m), 1800 (30m - default), 3600 (60m), 7200 (120m)
|
||||
*/
|
||||
function get_session_idle_timeout(): int {
|
||||
$timeout = (int)get_system_setting('session_idle_timeout', 1800);
|
||||
$validTimeouts = [900, 1800, 3600, 7200];
|
||||
return in_array($timeout, $validTimeouts, true) ? $timeout : 1800;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de beheerbare dagblokkade-redenen op (bijv. Vakantie, Verlof, Ziek, Focusdag, ADV).
|
||||
* Standaard voorzien van een duidelijke titel, emoji-icoon en actief-status.
|
||||
*/
|
||||
function get_calendar_block_reasons(): array {
|
||||
$defaultReasons = [
|
||||
['code' => 'VACATION', 'title' => 'Vakantie', 'icon' => '🏖️', 'is_active' => 1],
|
||||
['code' => 'LEAVE', 'title' => 'Verlof / Vrij', 'icon' => '✈️', 'is_active' => 1],
|
||||
['code' => 'SICK', 'title' => 'Ziek / Afwezig', 'icon' => '🤒', 'is_active' => 1],
|
||||
['code' => 'FOCUS', 'title' => 'Focusdag', 'icon' => '🎯', 'is_active' => 1],
|
||||
['code' => 'ADV', 'title' => 'ADV Dag', 'icon' => '📋', 'is_active' => 1],
|
||||
];
|
||||
|
||||
$saved = get_system_setting('calendar_block_reasons', null);
|
||||
if (is_array($saved) && !empty($saved)) {
|
||||
return $saved;
|
||||
}
|
||||
return $defaultReasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de beheerbare brainstorm categorieën op.
|
||||
* Standaard voorzien van een duidelijke categorienaam, beschrijving en actief-status.
|
||||
*/
|
||||
function get_brainstorm_categories(): array {
|
||||
$defaultCategories = [
|
||||
['name' => 'Product & Strategie', 'description' => 'Strategische richting, nieuwe producten en visie', 'is_active' => 1],
|
||||
['name' => 'Procesverbetering', 'description' => 'Optimalisatie van werkprocessen en efficiëntie', 'is_active' => 1],
|
||||
['name' => 'Klant & Service', 'description' => 'Klanttevredenheid, support en klantervaring', 'is_active' => 1],
|
||||
['name' => 'Technologie & IT', 'description' => 'Architectuur, tooling, infrastructuur en tech stack','is_active' => 1],
|
||||
['name' => 'Marketing & Groei', 'description' => 'Leadgeneratie, branding en marktpositie', 'is_active' => 1],
|
||||
];
|
||||
|
||||
$saved = get_system_setting('brainstorm_categories', null);
|
||||
if (is_array($saved) && !empty($saved)) {
|
||||
return $saved;
|
||||
}
|
||||
return $defaultCategories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registreert AI verbruiksstatistieken (aanroepen, tokens, provider/model)
|
||||
*/
|
||||
function record_ai_usage(string $provider, string $model, int $promptTokens = 0, int $completionTokens = 0, ?int $userId = null): void {
|
||||
try {
|
||||
$stats = get_system_setting('ai_usage_stats', [
|
||||
'total_requests' => 0,
|
||||
'total_prompt_tokens' => 0,
|
||||
'total_completion_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
'last_used_at' => null,
|
||||
'by_provider' => [],
|
||||
'by_model' => [],
|
||||
'by_date' => [],
|
||||
]);
|
||||
|
||||
$stats['total_requests'] = (int)($stats['total_requests'] ?? 0) + 1;
|
||||
$stats['total_prompt_tokens'] = (int)($stats['total_prompt_tokens'] ?? 0) + $promptTokens;
|
||||
$stats['total_completion_tokens'] = (int)($stats['total_completion_tokens'] ?? 0) + $completionTokens;
|
||||
$stats['total_tokens'] = (int)($stats['total_tokens'] ?? 0) + ($promptTokens + $completionTokens);
|
||||
$stats['last_used_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
// Per provider
|
||||
if (!isset($stats['by_provider'][$provider])) {
|
||||
$stats['by_provider'][$provider] = 0;
|
||||
}
|
||||
$stats['by_provider'][$provider]++;
|
||||
|
||||
// Per model
|
||||
if (!isset($stats['by_model'][$model])) {
|
||||
$stats['by_model'][$model] = 0;
|
||||
}
|
||||
$stats['by_model'][$model]++;
|
||||
|
||||
// Per datum
|
||||
$today = date('Y-m-d');
|
||||
if (!isset($stats['by_date'][$today])) {
|
||||
$stats['by_date'][$today] = ['requests' => 0, 'tokens' => 0];
|
||||
}
|
||||
$stats['by_date'][$today]['requests']++;
|
||||
$stats['by_date'][$today]['tokens'] += ($promptTokens + $completionTokens);
|
||||
|
||||
// Maximaal 30 dagen historiek
|
||||
if (count($stats['by_date']) > 30) {
|
||||
ksort($stats['by_date']);
|
||||
$stats['by_date'] = array_slice($stats['by_date'], -30, 30, true);
|
||||
}
|
||||
|
||||
set_system_setting('ai_usage_stats', $stats);
|
||||
} catch (Throwable $e) {
|
||||
error_log("record_ai_usage fout: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de AI verbruiksstatistieken op
|
||||
*/
|
||||
function get_ai_usage_stats(): array {
|
||||
$stats = get_system_setting('ai_usage_stats', null);
|
||||
if (!is_array($stats)) {
|
||||
return [
|
||||
'total_requests' => 0,
|
||||
'total_prompt_tokens' => 0,
|
||||
'total_completion_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
'last_used_at' => null,
|
||||
'by_provider' => [],
|
||||
'by_model' => [],
|
||||
'by_date' => [],
|
||||
];
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -107,12 +107,14 @@ class TOTP {
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een veilige client-side QR code (geen externe API calls meer ter bescherming van TOTP secrets)
|
||||
* Genereert een QR code afbeelding URL (via QuickChart HTTPS)
|
||||
*/
|
||||
public static function getQrCodeUrl(string $otpAuthUrl, int $size = 220): string {
|
||||
// Externe API (quickchart.io) verwijderd om te voorkomen dat 2FA secrets uitlekken naar derden.
|
||||
// QR rendering gebeurt nu 100% lokaal via /assets/js/qrcode.min.js in de browser.
|
||||
return '';
|
||||
return sprintf(
|
||||
'https://quickchart.io/qr?text=%s&size=%d&margin=1&ecLevel=M',
|
||||
urlencode($otpAuthUrl),
|
||||
$size
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,17 +130,3 @@ class TOTP {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Globale helper functies voor 2FA TOTP verificatie
|
||||
*/
|
||||
if (!function_exists('verify_totp')) {
|
||||
function verify_totp(string $secret, string $userInputCode, int $window = 1): bool {
|
||||
return TOTP::verifyCode($secret, $userInputCode, $window);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('get_totp_code')) {
|
||||
function get_totp_code(string $secret, ?int $timestamp = null): string {
|
||||
return TOTP::getCode($secret, $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ if (!has_admin_user()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = get_logged_in_user();
|
||||
if ($user) {
|
||||
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
|
||||
header('Location: /planner.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,701 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Integraties Doorverwijzer
|
||||
* Beheerders worden automatisch doorgeleid naar Systeembeheer (/admin.php).
|
||||
* Collega's worden automatisch doorgeleid naar hun Persoonlijk Profiel (/profile.php).
|
||||
* Fociz Integraties Hub (Microsoft Outlook, Microsoft Teams, Atlassian Jira)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
require_once __DIR__ . '/includes/integrations/microsoft.php';
|
||||
require_once __DIR__ . '/includes/integrations/jira.php';
|
||||
|
||||
$user = require_auth();
|
||||
$db = get_db_connection();
|
||||
$userId = $currentUser['id'];
|
||||
|
||||
$queryString = !empty($_SERVER['QUERY_STRING']) ? '?' . $_SERVER['QUERY_STRING'] : '';
|
||||
$message = null;
|
||||
$messageType = 'success';
|
||||
|
||||
if ($user['role'] === 'ADMIN') {
|
||||
header('Location: /admin.php' . $queryString);
|
||||
} else {
|
||||
header('Location: /profile.php' . $queryString);
|
||||
// Query parameters (bijv. na OAuth redirect)
|
||||
if (!empty($_GET['success'])) {
|
||||
$message = $_GET['success'];
|
||||
$messageType = 'success';
|
||||
} elseif (!empty($_GET['error'])) {
|
||||
$message = $_GET['error'];
|
||||
$messageType = 'error';
|
||||
}
|
||||
exit;
|
||||
|
||||
// POST Verwerking
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$provider = strtoupper(trim($_POST['provider'] ?? ''));
|
||||
$action = $_POST['action'] ?? 'save';
|
||||
|
||||
// 1. Ontkoppelen
|
||||
if ($action === 'disconnect') {
|
||||
if ($provider === 'OUTLOOK') {
|
||||
microsoft_disconnect($userId);
|
||||
$message = "Microsoft Outlook is succesvol ontkoppeld.";
|
||||
} elseif ($provider === 'JIRA') {
|
||||
jira_disconnect($userId);
|
||||
$message = "Atlassian Jira is succesvol ontkoppeld.";
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Outlook Opslaan
|
||||
elseif ($provider === 'OUTLOOK') {
|
||||
$clientId = trim($_POST['client_id'] ?? '');
|
||||
$clientSecret = trim($_POST['client_secret'] ?? '');
|
||||
$tenantId = trim($_POST['tenant_id'] ?? 'common');
|
||||
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
|
||||
|
||||
// Haal bestaande tokens op
|
||||
$stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||
$stmtExisting->execute([$userId]);
|
||||
$existing = $stmtExisting->fetch();
|
||||
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
|
||||
|
||||
// Als geheim veld leeg is gelaten, behoud het bestaande geheim
|
||||
if (empty($clientSecret) && !empty($existingSettings['client_secret'])) {
|
||||
$clientSecret = $existingSettings['client_secret'];
|
||||
}
|
||||
|
||||
$newSettings = array_merge($existingSettings, [
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
|
||||
VALUES (?, 'OUTLOOK', 1, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
is_demo_mode = VALUES(is_demo_mode),
|
||||
settings = VALUES(settings),
|
||||
last_sync_at = NOW()
|
||||
");
|
||||
$stmt->execute([$userId, $isDemo, json_encode($newSettings)]);
|
||||
$message = "Instellingen voor Microsoft Outlook zijn opgeslagen.";
|
||||
|
||||
// Als gebruiker direct op 'Koppel nu' klikte
|
||||
if ($action === 'connect_oauth') {
|
||||
header("Location: /auth/oauth_microsoft.php?client_id=" . urlencode($clientId) . "&tenant_id=" . urlencode($tenantId));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Teams Opslaan
|
||||
elseif ($provider === 'TEAMS') {
|
||||
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, last_sync_at)
|
||||
VALUES (?, 'TEAMS', 1, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE is_demo_mode = VALUES(is_demo_mode), last_sync_at = NOW()
|
||||
");
|
||||
$stmt->execute([$userId, $isDemo]);
|
||||
$message = "Instellingen voor Microsoft Teams zijn bijgewerkt.";
|
||||
}
|
||||
|
||||
// 4. Jira Opslaan & Testen
|
||||
elseif ($provider === 'JIRA') {
|
||||
$jiraDomain = jira_normalize_domain($_POST['jira_domain'] ?? '');
|
||||
$jiraEmail = trim($_POST['jira_email'] ?? '');
|
||||
$jiraToken = trim($_POST['jira_api_token'] ?? '');
|
||||
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
|
||||
|
||||
// Haal bestaande instellingen op
|
||||
$stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
|
||||
$stmtExisting->execute([$userId]);
|
||||
$existing = $stmtExisting->fetch();
|
||||
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
|
||||
|
||||
if (empty($jiraToken) && !empty($existingSettings['jira_api_token'])) {
|
||||
$jiraToken = $existingSettings['jira_api_token'];
|
||||
}
|
||||
|
||||
// Als niet in demo mode, test verbinding
|
||||
$accountName = $existingSettings['account_name'] ?? '';
|
||||
$accountId = $existingSettings['account_id'] ?? '';
|
||||
if (!$isDemo && !empty($jiraDomain) && !empty($jiraEmail) && !empty($jiraToken)) {
|
||||
$testRes = jira_test_connection($jiraDomain, $jiraEmail, $jiraToken);
|
||||
if (!$testRes['success']) {
|
||||
$message = "Jira Verbindingsfout: " . $testRes['error'];
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$accountName = $testRes['user']['displayName'] ?? 'Jira Gebruiker';
|
||||
$accountId = $testRes['user']['accountId'] ?? '';
|
||||
$message = "Verbinding met Jira geslaagd! Ingelogd als: {$accountName}";
|
||||
}
|
||||
} else {
|
||||
$message = "Jira instellingen opgeslagen.";
|
||||
}
|
||||
|
||||
$newSettings = [
|
||||
'jira_domain' => $jiraDomain,
|
||||
'jira_email' => $jiraEmail,
|
||||
'jira_api_token' => $jiraToken,
|
||||
'account_name' => $accountName,
|
||||
'account_id' => $accountId,
|
||||
];
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
|
||||
VALUES (?, 'JIRA', 1, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
is_demo_mode = VALUES(is_demo_mode),
|
||||
settings = VALUES(settings),
|
||||
last_sync_at = NOW()
|
||||
");
|
||||
$stmt->execute([$userId, $isDemo, json_encode($newSettings)]);
|
||||
|
||||
// Wis cache
|
||||
unset($_SESSION["jira_issues_user_{$userId}"]);
|
||||
}
|
||||
}
|
||||
|
||||
// Haal huidige configuraties op
|
||||
$stmtConfigs = $db->prepare("SELECT * FROM integrations WHERE user_id = ?");
|
||||
$stmtConfigs->execute([$userId]);
|
||||
$configsRaw = $stmtConfigs->fetchAll();
|
||||
$configs = [];
|
||||
foreach ($configsRaw as $c) {
|
||||
$configs[$c['provider']] = $c;
|
||||
$configs[$c['provider']]['settings_parsed'] = json_decode($c['settings'] ?? '{}', true) ?: [];
|
||||
}
|
||||
|
||||
// Bepaal statussen
|
||||
$isOutlookConnected = !empty($configs['OUTLOOK']['access_token']) && empty($configs['OUTLOOK']['is_demo_mode']);
|
||||
$outlookSettings = $configs['OUTLOOK']['settings_parsed'] ?? [];
|
||||
|
||||
$isJiraConnected = !empty($configs['JIRA']) && empty($configs['JIRA']['is_demo_mode']) && !empty($configs['JIRA']['settings_parsed']['jira_api_token']);
|
||||
$jiraSettings = $configs['JIRA']['settings_parsed'] ?? [];
|
||||
|
||||
// Haal actuele Jira issues op
|
||||
$forceRefresh = isset($_GET['refresh_jira']);
|
||||
$jiraData = jira_get_user_issues($userId, $forceRefresh);
|
||||
$jiraIssues = $jiraData['issues'] ?? [];
|
||||
$jiraIsDemo = !empty($jiraData['is_demo']);
|
||||
|
||||
// Callback redirect URI
|
||||
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
|
||||
?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-r from-slate-900 via-[#0d1527] to-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 text-blue-400 text-xs font-semibold uppercase tracking-wider mb-1">
|
||||
<i data-lucide="layers" class="w-4 h-4"></i>
|
||||
<span>Live Koppelingen & Data Synchronisatie</span>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
Integraties Hub
|
||||
</h1>
|
||||
<p class="text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
||||
Koppel Microsoft 365 Outlook (voor realtime agenda & Teams meetings) en Atlassian Jira Cloud (om direct uw toegewezen tickets in te plannen).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Snelle status pills -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl border text-xs font-semibold <?= $isOutlookConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-slate-800/80 border-slate-700 text-slate-400' ?>">
|
||||
<span class="w-2 h-2 rounded-full <?= $isOutlookConnected ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500' ?>"></span>
|
||||
<span>Outlook: <?= $isOutlookConnected ? 'Verbonden' : 'Niet verbonden' ?></span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl border text-xs font-semibold <?= $isJiraConnected ? 'bg-sky-500/10 border-sky-500/30 text-sky-400' : 'bg-slate-800/80 border-slate-700 text-slate-400' ?>">
|
||||
<span class="w-2 h-2 rounded-full <?= $isJiraConnected ? 'bg-sky-400 animate-pulse' : 'bg-slate-500' ?>"></span>
|
||||
<span>Jira: <?= $isJiraConnected ? 'Verbonden' : 'Niet verbonden' ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash message -->
|
||||
<?php if ($message): ?>
|
||||
<div class="p-4 rounded-xl text-xs font-semibold flex items-center justify-between border <?= $messageType === 'error' ? 'bg-red-500/10 border-red-500/30 text-red-400' : 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' ?>">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="<?= $messageType === 'error' ? 'alert-triangle' : 'check-circle' ?>" class="w-4 h-4"></i>
|
||||
<span><?= htmlspecialchars($message) ?></span>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="text-slate-500 hover:text-slate-300">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 3 Koloms Grid voor Providers -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- 1. MICROSOFT OUTLOOK -->
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between relative overflow-hidden backdrop-blur-sm">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-400 flex items-center justify-center font-bold">
|
||||
<i data-lucide="calendar" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<?php if ($isOutlookConnected): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold flex items-center space-x-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
<span>Live Verbonden</span>
|
||||
</span>
|
||||
<?php elseif (!empty($configs['OUTLOOK']['is_demo_mode'])): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-semibold">
|
||||
Sandbox Modus
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-400 text-xs font-semibold">
|
||||
Niet Gekoppeld
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-white mb-1">Microsoft Outlook (365)</h3>
|
||||
<p class="text-xs text-slate-400 leading-relaxed">
|
||||
Synchroniseert automatisch uw zakelijke agenda-afspraken en meetings naar de dagplanner tijdlijn.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($isOutlookConnected): ?>
|
||||
<div class="bg-slate-950/70 rounded-xl p-3.5 border border-emerald-500/20 text-xs space-y-2">
|
||||
<div class="flex items-center justify-between text-slate-400">
|
||||
<span>Account:</span>
|
||||
<span class="text-emerald-400 font-semibold truncate max-w-[180px]" title="<?= htmlspecialchars($outlookSettings['account_email'] ?? '') ?>">
|
||||
<?= htmlspecialchars($outlookSettings['account_email'] ?? 'Verbonden') ?>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-slate-400">
|
||||
<span>Naam:</span>
|
||||
<span class="text-slate-200"><?= htmlspecialchars($outlookSettings['account_name'] ?? '-') ?></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-slate-400">
|
||||
<span>Laatste Sync:</span>
|
||||
<span class="text-slate-300 font-mono text-[11px]"><?= htmlspecialchars($configs['OUTLOOK']['last_sync_at'] ?? 'Zojuist') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Azure App Instructies Accordion -->
|
||||
<details class="group text-xs bg-slate-950/50 rounded-xl border border-slate-800/80 p-3">
|
||||
<summary class="cursor-pointer font-semibold text-blue-400 flex items-center justify-between">
|
||||
<span class="flex items-center space-x-1.5">
|
||||
<i data-lucide="help-circle" class="w-3.5 h-3.5"></i>
|
||||
<span>Azure App Registratie Instructies</span>
|
||||
</span>
|
||||
<i data-lucide="chevron-down" class="w-3.5 h-3.5 group-open:rotate-180 transition"></i>
|
||||
</summary>
|
||||
<div class="mt-3 space-y-2 text-slate-400 text-[11px] leading-relaxed border-t border-slate-800/60 pt-2">
|
||||
<p>1. Open <a href="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade" target="_blank" class="text-blue-400 underline">Azure App Registraties</a> en maak een nieuwe registratie.</p>
|
||||
<p>2. Kies <em>Accounts in elke organisatiemap (...) en persoonlijke Microsoft-accounts</em>.</p>
|
||||
<p>3. Voeg een <strong>Web</strong> Omleidings-URI toe:</p>
|
||||
<div class="flex items-center space-x-1 bg-slate-900 p-1.5 rounded border border-slate-800">
|
||||
<input type="text" readonly id="msRedirectUriInput" value="<?= htmlspecialchars($redirectUri) ?>" class="bg-transparent text-[10px] text-emerald-400 font-mono flex-1 focus:outline-none">
|
||||
<button onclick="copyToClipboard('msRedirectUriInput')" class="text-slate-400 hover:text-white px-1.5 py-0.5 rounded bg-slate-800 text-[10px]">Kopieer</button>
|
||||
</div>
|
||||
<p>4. Maak een geheim aan onder <em>Certificaten en geheimen</em>.</p>
|
||||
<p>5. Voeg API-machtigingen toe: <code>Calendars.Read</code>, <code>User.Read</code>, <code>offline_access</code>.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Instellingen formulier -->
|
||||
<form method="POST" action="/integrations.php" class="pt-4 border-t border-slate-800 space-y-2.5 mt-4">
|
||||
<input type="hidden" name="provider" value="OUTLOOK">
|
||||
|
||||
<div>
|
||||
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Application (Client) ID</label>
|
||||
<input
|
||||
type="text"
|
||||
name="client_id"
|
||||
value="<?= htmlspecialchars($outlookSettings['client_id'] ?? fociz_env('MICROSOFT_CLIENT_ID', '')) ?>"
|
||||
placeholder="e.g. 11111111-2222-3333-4444-555555555555"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 font-mono"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Client Secret</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
id="msClientSecret"
|
||||
name="client_secret"
|
||||
value="<?= !empty($outlookSettings['client_secret']) ? '••••••••••••••••' : htmlspecialchars(fociz_env('MICROSOFT_CLIENT_SECRET', '')) ?>"
|
||||
placeholder="Client Secret Value"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 pr-8 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 font-mono"
|
||||
>
|
||||
<button type="button" onclick="togglePasswordVisibility('msClientSecret')" class="absolute right-2 top-2 text-slate-500 hover:text-slate-300">
|
||||
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Directory (Tenant) ID</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tenant_id"
|
||||
value="<?= htmlspecialchars($outlookSettings['tenant_id'] ?? fociz_env('MICROSOFT_TENANT_ID', 'common')) ?>"
|
||||
placeholder="common of specifiek GUID"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 font-mono"
|
||||
>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center space-x-2 text-xs text-slate-300 pt-1">
|
||||
<input type="checkbox" name="is_demo_mode" value="1" <?= !empty($configs['OUTLOOK']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-blue-500">
|
||||
<span>Gebruik interactieve Sandbox data</span>
|
||||
</label>
|
||||
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button type="submit" name="action" value="save" class="flex-1 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition">
|
||||
Opslaan
|
||||
</button>
|
||||
|
||||
<button type="submit" name="action" value="connect_oauth" class="flex-1 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition flex items-center justify-center space-x-1 shadow-md shadow-blue-500/20">
|
||||
<i data-lucide="key" class="w-3.5 h-3.5"></i>
|
||||
<span><?= $isOutlookConnected ? 'Opnieuw Koppelen' : 'Koppel Outlook' ?></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php if ($isOutlookConnected): ?>
|
||||
<button type="submit" name="action" value="disconnect" onclick="return confirm('Weet u zeker dat u Microsoft Outlook wilt ontkoppelen?')" class="w-full py-1.5 text-[11px] text-red-400 hover:text-red-300 transition text-center block">
|
||||
Koppeling Verwijderen
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 2. MICROSOFT TEAMS -->
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between relative overflow-hidden backdrop-blur-sm">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-10 h-10 rounded-xl bg-indigo-500/10 border border-indigo-500/30 text-indigo-400 flex items-center justify-center font-bold">
|
||||
<i data-lucide="video" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium flex items-center space-x-1">
|
||||
<i data-lucide="check-circle" class="w-3 h-3"></i>
|
||||
<span>Inbegrepen bij Outlook</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-white mb-1">Microsoft Teams</h3>
|
||||
<p class="text-xs text-slate-400 leading-relaxed">
|
||||
Detecteert Teams vergaderingen automatisch vanuit uw Outlook agenda en toont directe 'Deelnemen' knoppen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-950/60 rounded-xl p-3.5 border border-slate-800/80 text-xs space-y-2">
|
||||
<div class="flex items-center justify-between text-slate-400">
|
||||
<span>Direct Deelnemen:</span>
|
||||
<span class="text-emerald-400 font-semibold">Ondersteund</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-slate-400">
|
||||
<span>Meeting detectie:</span>
|
||||
<span class="text-slate-200">Via Graph API</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-slate-400">
|
||||
<span>Status:</span>
|
||||
<span class="text-indigo-400 font-semibold"><?= $isOutlookConnected ? 'Actief' : 'Koppel Outlook' ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-indigo-950/30 border border-indigo-900/50 rounded-xl text-xs text-indigo-200/80">
|
||||
<p class="leading-relaxed">
|
||||
Teams vergaderingen worden automatisch uitgelezen zodra Microsoft Outlook is gekoppeld. U hoeft voor Teams geen aparte koppeling te configureren!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/integrations.php" class="pt-4 border-t border-slate-800 space-y-3 mt-4">
|
||||
<input type="hidden" name="provider" value="TEAMS">
|
||||
<input type="hidden" name="action" value="save">
|
||||
|
||||
<label class="flex items-center space-x-2 text-xs text-slate-300">
|
||||
<input type="checkbox" name="is_demo_mode" value="1" <?= empty($configs['TEAMS']) || !empty($configs['TEAMS']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-indigo-500">
|
||||
<span>Gebruik interactieve Sandbox data</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="w-full py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-white text-xs font-semibold transition">
|
||||
Teams Instellingen Opslaan
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 3. ATLASSIAN JIRA -->
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between relative overflow-hidden backdrop-blur-sm">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-10 h-10 rounded-xl bg-sky-500/10 border border-sky-500/30 text-sky-400 flex items-center justify-center font-bold">
|
||||
<i data-lucide="trello" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<?php if ($isJiraConnected): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold flex items-center space-x-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
<span>Live Verbonden</span>
|
||||
</span>
|
||||
<?php elseif (!empty($configs['JIRA']['is_demo_mode'])): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-semibold">
|
||||
Sandbox Modus
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-400 text-xs font-semibold">
|
||||
Niet Gekoppeld
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-white mb-1">Atlassian Jira Cloud</h3>
|
||||
<p class="text-xs text-slate-400 leading-relaxed">
|
||||
Koppelt uw Jira tickets rechtstreeks aan de Fociz dagplanning en roadmap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($isJiraConnected): ?>
|
||||
<div class="bg-slate-950/70 rounded-xl p-3.5 border border-sky-500/20 text-xs space-y-2">
|
||||
<div class="flex items-center justify-between text-slate-400">
|
||||
<span>Ingelogd als:</span>
|
||||
<span class="text-sky-400 font-semibold truncate max-w-[170px]" title="<?= htmlspecialchars($jiraSettings['jira_email'] ?? '') ?>">
|
||||
<?= htmlspecialchars($jiraSettings['account_name'] ?: ($jiraSettings['jira_email'] ?? '')) ?>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-slate-400">
|
||||
<span>Domein:</span>
|
||||
<span class="text-slate-200 font-mono text-[11px] truncate max-w-[170px]"><?= htmlspecialchars($jiraSettings['jira_domain'] ?? '-') ?></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-slate-400">
|
||||
<span>Tickets Toegewezen:</span>
|
||||
<span class="text-emerald-400 font-bold"><?= count($jiraIssues) ?> open taken</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bg-slate-950/50 rounded-xl border border-slate-800/80 p-3 text-xs text-slate-400">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300 font-semibold">Atlassian API Token nodig?</span>
|
||||
<a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" class="text-sky-400 hover:text-sky-300 font-semibold flex items-center space-x-1">
|
||||
<span>Genereer Token</span>
|
||||
<i data-lucide="external-link" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jira Formulier -->
|
||||
<form method="POST" action="/integrations.php" class="pt-4 border-t border-slate-800 space-y-2.5 mt-4">
|
||||
<input type="hidden" name="provider" value="JIRA">
|
||||
|
||||
<div>
|
||||
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Jira Cloud Domein</label>
|
||||
<input
|
||||
type="text"
|
||||
name="jira_domain"
|
||||
value="<?= htmlspecialchars($jiraSettings['jira_domain'] ?? fociz_env('JIRA_DOMAIN', '')) ?>"
|
||||
placeholder="uwbedrijf.atlassian.net"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-sky-500 font-mono"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Atlassian E-mailadres</label>
|
||||
<input
|
||||
type="email"
|
||||
name="jira_email"
|
||||
value="<?= htmlspecialchars($jiraSettings['jira_email'] ?? fociz_env('JIRA_EMAIL', '')) ?>"
|
||||
placeholder="collega@uwbedrijf.nl"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-sky-500"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Atlassian API Token</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
id="jiraApiTokenInput"
|
||||
name="jira_api_token"
|
||||
value="<?= !empty($jiraSettings['jira_api_token']) ? '••••••••••••••••' : htmlspecialchars(fociz_env('JIRA_API_TOKEN', '')) ?>"
|
||||
placeholder="Plak hier uw Atlassian API token"
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 pr-8 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-sky-500 font-mono"
|
||||
>
|
||||
<button type="button" onclick="togglePasswordVisibility('jiraApiTokenInput')" class="absolute right-2 top-2 text-slate-500 hover:text-slate-300">
|
||||
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center space-x-2 text-xs text-slate-300 pt-1">
|
||||
<input type="checkbox" name="is_demo_mode" value="1" <?= empty($configs['JIRA']) || !empty($configs['JIRA']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-sky-500">
|
||||
<span>Gebruik interactieve Sandbox data</span>
|
||||
</label>
|
||||
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button type="submit" name="action" value="save" class="w-full py-2 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-xs font-bold transition flex items-center justify-center space-x-1.5 shadow-md shadow-sky-500/20">
|
||||
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
|
||||
<span>Verbinding Testen & Opslaan</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php if ($isJiraConnected): ?>
|
||||
<button type="submit" name="action" value="disconnect" onclick="return confirm('Weet u zeker dat u Atlassian Jira wilt ontkoppelen?')" class="w-full py-1.5 text-[11px] text-red-400 hover:text-red-300 transition text-center block">
|
||||
Koppeling Verwijderen
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gesynchroniseerde Jira Issues & 1-Click Import naar Dagplanning -->
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-slate-800">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="check-square" class="w-5 h-5 text-sky-400"></i>
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider">
|
||||
Jira Issues Toegewezen aan U
|
||||
</h3>
|
||||
<?php if ($jiraIsDemo): ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">Sandbox</span>
|
||||
<?php else: ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">Live Jira</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-xs text-slate-400 font-mono"><?= count($jiraIssues) ?> issues</span>
|
||||
<a href="/integrations.php?refresh_jira=1" class="flex items-center space-x-1 text-xs text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 px-2.5 py-1 rounded-lg border border-slate-700 transition">
|
||||
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
|
||||
<span>Verversen</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($jiraIssues)): ?>
|
||||
<div class="text-center py-10 text-slate-500 text-xs">
|
||||
Geen openstaande tickets gevonden die aan u zijn toegewezen in Jira.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<?php foreach ($jiraIssues as $issue):
|
||||
$priority = $issue['priority'] ?? 'Medium';
|
||||
$priorityClass = match(strtolower($priority)) {
|
||||
'highest', 'high', 'hoog' => 'text-red-400 bg-red-500/10 border-red-500/20',
|
||||
'medium', 'gemiddeld' => 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
default => 'text-slate-400 bg-slate-800/60 border-slate-700',
|
||||
};
|
||||
?>
|
||||
<div class="bg-slate-950/70 border border-slate-800/80 hover:border-slate-700 rounded-xl p-4 flex flex-col justify-between transition group">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="font-mono text-xs font-bold text-sky-400 bg-sky-500/10 border border-sky-500/20 px-2 py-0.5 rounded">
|
||||
<?= htmlspecialchars($issue['key']) ?>
|
||||
</span>
|
||||
<span class="text-[10px] text-slate-400 font-medium truncate max-w-[140px]">
|
||||
<?= htmlspecialchars($issue['project'] ?? '') ?>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-semibold px-2 py-0.5 rounded bg-slate-800 text-slate-300">
|
||||
<?= htmlspecialchars($issue['status']) ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 class="text-xs font-bold text-white leading-snug group-hover:text-sky-300 transition">
|
||||
<?= htmlspecialchars($issue['summary']) ?>
|
||||
</h4>
|
||||
|
||||
<div class="flex items-center space-x-3 mt-2 text-[10px] text-slate-500">
|
||||
<span>Type: <?= htmlspecialchars($issue['issue_type'] ?? 'Issue') ?></span>
|
||||
<?php if (!empty($issue['due_date'])): ?>
|
||||
<span>Vervaldatum: <?= htmlspecialchars($issue['due_date']) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-3 border-t border-slate-800/80 flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold px-2 py-0.5 rounded border <?= $priorityClass ?>">
|
||||
<?= htmlspecialchars($priority) ?>
|
||||
</span>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<?php if (!empty($issue['url']) && $issue['url'] !== '#'): ?>
|
||||
<a href="<?= htmlspecialchars($issue['url']) ?>" target="_blank" class="p-1 rounded-lg text-slate-500 hover:text-sky-400 transition" title="Open in Jira">
|
||||
<i data-lucide="external-link" class="w-3.5 h-3.5"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<button
|
||||
onclick="importJiraToTasks(this, '<?= htmlspecialchars(addslashes($issue['summary'])) ?>', '<?= htmlspecialchars($issue['key']) ?>', '<?= htmlspecialchars(addslashes($issue['url'] ?? '')) ?>', 'P1')"
|
||||
class="flex items-center space-x-1 text-[11px] font-semibold text-emerald-400 hover:text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 px-2.5 py-1 rounded-lg transition"
|
||||
>
|
||||
<i data-lucide="plus" class="w-3 h-3"></i>
|
||||
<span>Plan als P1</span>
|
||||
</button>
|
||||
<button
|
||||
onclick="importJiraToTasks(this, '<?= htmlspecialchars(addslashes($issue['summary'])) ?>', '<?= htmlspecialchars($issue['key']) ?>', '<?= htmlspecialchars(addslashes($issue['url'] ?? '')) ?>', 'P2')"
|
||||
class="flex items-center space-x-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 px-2.5 py-1 rounded-lg transition"
|
||||
>
|
||||
<span>Plan P2</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
navigator.clipboard.writeText(el.value).then(() => {
|
||||
alert('Omleidings-URI gekopieerd naar klembord!');
|
||||
}).catch(() => {
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
alert('Omleidings-URI gekopieerd!');
|
||||
});
|
||||
}
|
||||
|
||||
function togglePasswordVisibility(inputId) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
async function importJiraToTasks(btn, title, jiraKey, url, priority = 'P1') {
|
||||
const originalHtml = btn.innerHTML;
|
||||
btn.innerText = 'Toevoegen...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/planner.php?action=add_task', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
title: `[${jiraKey}] ${title}`,
|
||||
priority: priority,
|
||||
source: 'JIRA',
|
||||
external_id: jiraKey,
|
||||
external_url: url,
|
||||
date: '<?= date('Y-m-d') ?>'
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
btn.innerHTML = '<i data-lucide="check" class="w-3 h-3"></i><span>Gepland!</span>';
|
||||
btn.className = 'flex items-center space-x-1 text-[11px] font-semibold text-emerald-400 bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
} else {
|
||||
btn.innerHTML = originalHtml;
|
||||
btn.disabled = false;
|
||||
alert(data.error || 'Kon taak niet toevoegen');
|
||||
}
|
||||
} catch(err) {
|
||||
btn.innerHTML = originalHtml;
|
||||
btn.disabled = false;
|
||||
alert('Netwerkfout bij toevoegen van taak');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
@@ -27,73 +27,45 @@ if ($selectedStatus !== 'ALL') {
|
||||
$params[] = $selectedStatus;
|
||||
}
|
||||
|
||||
$roadmapItems = [];
|
||||
try {
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$roadmapItems = $stmt->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen roadmap items: " . $e->getMessage());
|
||||
ensure_tables_exist($db);
|
||||
}
|
||||
$sql .= " ORDER BY r.quarter ASC, r.priority DESC, r.due_date ASC";
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$roadmapItems = $stmt->fetchAll();
|
||||
|
||||
$quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
$currentUserId = (int)($currentUser['id'] ?? 0);
|
||||
|
||||
// Haal alle actieve teamleden op voor toewijzing & agendablokkades
|
||||
$teamMembersStmt = $db->query("SELECT id, name, email, role, avatar_url FROM users ORDER BY name ASC");
|
||||
$teamMembers = $teamMembersStmt->fetchAll();
|
||||
|
||||
$outlookUsers = [];
|
||||
$intStmt = $db->query("SELECT user_id FROM integrations WHERE provider = 'OUTLOOK' AND is_enabled = 1 AND access_token IS NOT NULL");
|
||||
foreach ($intStmt->fetchAll() as $row) {
|
||||
$outlookUsers[(int)$row['user_id']] = true;
|
||||
}
|
||||
foreach ($teamMembers as &$tm) {
|
||||
$tm['has_outlook'] = !empty($outlookUsers[(int)$tm['id']]);
|
||||
}
|
||||
unset($tm);
|
||||
?>
|
||||
|
||||
<div class="space-y-6 max-w-7xl mx-auto">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="fociz-card p-6 sm:p-7 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<div class="flex items-center space-x-2 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-1">
|
||||
<i data-lucide="map" class="w-4 h-4"></i>
|
||||
<span>Planning & Strategie</span>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
Roadmap & Tijdlijn
|
||||
</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
||||
<p class="text-slate-400 text-xs sm:text-sm mt-1">
|
||||
Centraal overzicht van alle mijlpalen, features en doelen, direct voortgekomen uit brainstorms en gesynchroniseerd met Jira.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button onclick="document.getElementById('newItemModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs flex-shrink-0 cursor-pointer">
|
||||
<button onclick="document.getElementById('newItemModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-emerald-500 hover:bg-emerald-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-500/20 flex-shrink-0">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
<span>Nieuw Item</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters: Quarters & Status -->
|
||||
<div class="fociz-card p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<!-- Quarter Tabs (tevens drop targets) -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 bg-slate-900/40 border border-slate-800/80 rounded-2xl p-4">
|
||||
<!-- Quarter Tabs -->
|
||||
<div class="flex items-center space-x-1 text-xs">
|
||||
<a href="/roadmap.php?quarter=ALL&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-lg transition font-semibold <?= $selectedQuarter === 'ALL' ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/80' ?>">
|
||||
<a href="/roadmap.php?quarter=ALL&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-xl transition font-medium <?= $selectedQuarter === 'ALL' ? 'bg-emerald-500 text-slate-950 font-bold' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800' ?>">
|
||||
Alle Quarters
|
||||
</a>
|
||||
<?php foreach ($quarters as $q): ?>
|
||||
<a
|
||||
href="/roadmap.php?quarter=<?= $q ?>&status=<?= htmlspecialchars($selectedStatus) ?>"
|
||||
class="roadmap-quarter-tab px-3.5 py-1.5 rounded-lg transition-all font-semibold <?= $selectedQuarter === $q ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/80' ?>"
|
||||
data-quarter="<?= $q ?>"
|
||||
ondragover="handleRoadmapQuarterDragOver(event)"
|
||||
ondragleave="handleRoadmapQuarterDragLeave(event)"
|
||||
ondrop="handleRoadmapQuarterDrop(event, '<?= $q ?>')"
|
||||
title="Sleep een roadmap item hierheen om het kwartaal direct te wijzigen"
|
||||
>
|
||||
<a href="/roadmap.php?quarter=<?= $q ?>&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-xl transition font-medium <?= $selectedQuarter === $q ? 'bg-emerald-500 text-slate-950 font-bold' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800' ?>">
|
||||
<?= $q ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
@@ -101,8 +73,8 @@ unset($tm);
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="flex items-center space-x-2 text-xs">
|
||||
<span class="text-slate-500 dark:text-slate-400 font-semibold">Status:</span>
|
||||
<select onchange="window.location.href='/roadmap.php?quarter=<?= htmlspecialchars($selectedQuarter) ?>&status='+this.value" class="bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-semibold focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<span class="text-slate-500">Status:</span>
|
||||
<select onchange="window.location.href='/roadmap.php?quarter=<?= htmlspecialchars($selectedQuarter) ?>&status='+this.value" class="bg-slate-800 border border-slate-700 rounded-xl px-2.5 py-1 text-white text-xs focus:outline-none">
|
||||
<option value="ALL" <?= $selectedStatus === 'ALL' ? 'selected' : '' ?>>Alle Statussen</option>
|
||||
<option value="PLANNED" <?= $selectedStatus === 'PLANNED' ? 'selected' : '' ?>>Gepland</option>
|
||||
<option value="IN_PROGRESS" <?= $selectedStatus === 'IN_PROGRESS' ? 'selected' : '' ?>>In Uitvoering</option>
|
||||
@@ -115,61 +87,38 @@ unset($tm);
|
||||
<!-- Roadmap Items Lijst -->
|
||||
<div class="space-y-4">
|
||||
<?php if (empty($roadmapItems)): ?>
|
||||
<div class="fociz-card p-12 text-center">
|
||||
<i data-lucide="map-pin" class="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white">Geen items gevonden op de roadmap</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
Ga naar een <a href="/brainstorm.php" class="text-indigo-600 dark:text-indigo-400 underline font-medium">Brainstorm sessie</a> en zet ideeën met één klik om naar de roadmap, of voeg handmatig een item toe.
|
||||
<div class="bg-slate-900/60 border border-slate-800/80 rounded-2xl p-12 text-center">
|
||||
<i data-lucide="map-pin" class="w-12 h-12 text-emerald-500/40 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold text-white">Geen items gevonden op de roadmap</h3>
|
||||
<p class="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
|
||||
Ga naar een <a href="/brainstorm.php" class="text-amber-400 underline">Brainstorm sessie</a> en zet ideeën met één klik om naar de roadmap, of voeg handmatig een item toe.
|
||||
</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($roadmapItems as $item):
|
||||
$statusBorder = match($item['status']) {
|
||||
'COMPLETED' => 'border-l-4 border-l-emerald-500',
|
||||
'IN_PROGRESS' => 'border-l-4 border-l-indigo-500',
|
||||
'REVIEW' => 'border-l-4 border-l-amber-500',
|
||||
default => 'border-l-4 border-l-slate-300 dark:border-l-slate-700',
|
||||
};
|
||||
?>
|
||||
<div
|
||||
id="roadmap-item-<?= $item['id'] ?>"
|
||||
draggable="true"
|
||||
ondragstart="handleRoadmapItemDragStart(event, <?= $item['id'] ?>)"
|
||||
ondragend="handleRoadmapItemDragEnd(event)"
|
||||
oncontextmenu="openRoadmapContextMenu(event, <?= $item['id'] ?>); return false;"
|
||||
data-title="<?= htmlspecialchars($item['title'], ENT_QUOTES) ?>"
|
||||
data-description="<?= htmlspecialchars($item['description'] ?? '', ENT_QUOTES) ?>"
|
||||
data-quarter="<?= htmlspecialchars($item['quarter']) ?>"
|
||||
data-type="<?= htmlspecialchars($item['type']) ?>"
|
||||
data-priority="<?= htmlspecialchars($item['priority']) ?>"
|
||||
data-status="<?= htmlspecialchars($item['status']) ?>"
|
||||
data-due-date="<?= htmlspecialchars($item['due_date'] ?? '') ?>"
|
||||
data-jira-key="<?= htmlspecialchars($item['jira_issue_key'] ?? '') ?>"
|
||||
class="fociz-card-interactive p-5 space-y-3.5 <?= $statusBorder ?> cursor-grab active:cursor-grabbing transition-all duration-150"
|
||||
title="Sleep naar een ander kwartaal (Q1, Q2, Q3, Q4) of klik met rechtermuisknop"
|
||||
>
|
||||
<?php foreach ($roadmapItems as $item): ?>
|
||||
<div class="bg-slate-900/80 border border-slate-800 hover:border-slate-700 rounded-2xl p-5 shadow-lg transition space-y-3" id="roadmap-item-<?= $item['id'] ?>">
|
||||
|
||||
<!-- Top header van item -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div class="flex items-center space-x-2.5 flex-wrap">
|
||||
<!-- Quarter badge -->
|
||||
<span class="px-2.5 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 font-mono text-xs font-bold border border-indigo-200 dark:border-indigo-500/20">
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 font-mono text-xs font-bold border border-emerald-500/30">
|
||||
<?= htmlspecialchars($item['quarter']) ?>
|
||||
</span>
|
||||
|
||||
<!-- Type badge -->
|
||||
<span class="px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-[10px] font-semibold border border-slate-200 dark:border-slate-700">
|
||||
<span class="px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 text-[10px] font-semibold">
|
||||
<?= htmlspecialchars($item['type']) ?>
|
||||
</span>
|
||||
|
||||
<!-- Priority badge -->
|
||||
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full <?= $item['priority'] === 'CRITICAL' ? 'bg-rose-50 dark:bg-rose-500/10 text-rose-700 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20' : ($item['priority'] === 'HIGH' ? 'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20' : 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700') ?>">
|
||||
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full <?= $item['priority'] === 'CRITICAL' ? 'bg-red-500/20 text-red-400' : ($item['priority'] === 'HIGH' ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-800 text-slate-400') ?>">
|
||||
<?= htmlspecialchars($item['priority']) ?>
|
||||
</span>
|
||||
|
||||
<!-- Jira Issue Key link -->
|
||||
<?php if (!empty($item['jira_issue_key'])): ?>
|
||||
<a href="/profile.php" class="flex items-center space-x-1 px-2.5 py-0.5 rounded-lg bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 text-sky-700 dark:text-sky-400 text-[10px] font-mono hover:underline">
|
||||
<a href="/integrations.php" class="flex items-center space-x-1 px-2 py-0.5 rounded-full bg-blue-950/60 border border-blue-800/60 text-blue-300 text-[10px] font-mono hover:underline">
|
||||
<i data-lucide="layers" class="w-3 h-3"></i>
|
||||
<span><?= htmlspecialchars($item['jira_issue_key']) ?></span>
|
||||
</a>
|
||||
@@ -178,14 +127,14 @@ unset($tm);
|
||||
|
||||
<!-- Status Selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<select onchange="updateRoadmapStatus(<?= $item['id'] ?>, this.value)" class="bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1 text-xs text-slate-800 dark:text-slate-200 font-bold focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<select onchange="updateRoadmapStatus(<?= $item['id'] ?>, this.value)" class="bg-slate-950 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-slate-200 font-semibold focus:outline-none">
|
||||
<option value="PLANNED" <?= $item['status'] === 'PLANNED' ? 'selected' : '' ?>>⏳ Gepland</option>
|
||||
<option value="IN_PROGRESS" <?= $item['status'] === 'IN_PROGRESS' ? 'selected' : '' ?>>⚡ In Uitvoering</option>
|
||||
<option value="REVIEW" <?= $item['status'] === 'REVIEW' ? 'selected' : '' ?>>🔍 In Review</option>
|
||||
<option value="COMPLETED" <?= $item['status'] === 'COMPLETED' ? 'selected' : '' ?>>✅ Voltooid</option>
|
||||
</select>
|
||||
|
||||
<button onclick="deleteRoadmapItem(<?= $item['id'] ?>)" title="Verwijderen" class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:text-slate-500 dark:hover:text-rose-400 hover:bg-rose-500/10 transition cursor-pointer">
|
||||
<button onclick="deleteRoadmapItem(<?= $item['id'] ?>)" title="Verwijderen" class="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -193,11 +142,11 @@ unset($tm);
|
||||
|
||||
<!-- Titel & Beschrijving -->
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white leading-snug">
|
||||
<h3 class="text-base font-bold text-white leading-snug">
|
||||
<?= htmlspecialchars($item['title']) ?>
|
||||
</h3>
|
||||
<?php if (!empty($item['description'])): ?>
|
||||
<p class="text-slate-600 dark:text-slate-400 text-xs mt-1 whitespace-pre-wrap leading-relaxed">
|
||||
<p class="text-slate-400 text-xs mt-1 whitespace-pre-wrap">
|
||||
<?= htmlspecialchars($item['description']) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
@@ -205,33 +154,33 @@ unset($tm);
|
||||
|
||||
<!-- Origin Brainstorm snippet -->
|
||||
<?php if (!empty($item['origin_brainstorm_card_id'])): ?>
|
||||
<div class="bg-slate-50/70 dark:bg-[#0c0f17]/60 rounded-xl p-2.5 text-[11px] text-slate-600 dark:text-slate-400 flex items-center space-x-2 border border-slate-200/80 dark:border-slate-800">
|
||||
<i data-lucide="lightbulb" class="w-3.5 h-3.5 text-amber-500 flex-shrink-0"></i>
|
||||
<div class="bg-slate-950/50 rounded-xl p-2.5 text-[11px] text-slate-400 flex items-center space-x-2 border border-slate-800">
|
||||
<i data-lucide="lightbulb" class="w-3.5 h-3.5 text-amber-400 flex-shrink-0"></i>
|
||||
<span class="truncate">Ontstaan uit brainstormsessie</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Voortgangsbalk & Deadline -->
|
||||
<div class="pt-2.5 border-t border-slate-100 dark:border-slate-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||
<div class="pt-2 border-t border-slate-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||
<!-- Progress Slider -->
|
||||
<div class="flex items-center space-x-3 flex-1 max-w-md">
|
||||
<span class="text-slate-500 dark:text-slate-400 text-[11px] font-semibold w-16">Voortgang:</span>
|
||||
<div class="flex-1 bg-slate-100 dark:bg-slate-900 h-2.5 rounded-full overflow-hidden border border-slate-200 dark:border-slate-800">
|
||||
<div class="bg-gradient-to-r from-indigo-600 to-sky-500 h-full transition-all" style="width: <?= $item['progress'] ?>%"></div>
|
||||
<span class="text-slate-400 text-[11px] font-medium w-16">Voortgang:</span>
|
||||
<div class="flex-1 bg-slate-950 h-2 rounded-full overflow-hidden border border-slate-800">
|
||||
<div class="bg-gradient-to-r from-emerald-500 to-teal-400 h-full transition-all" style="width: <?= $item['progress'] ?>%"></div>
|
||||
</div>
|
||||
<span class="font-mono text-slate-800 dark:text-slate-200 font-bold w-10 text-right"><?= $item['progress'] ?>%</span>
|
||||
<span class="font-mono text-slate-200 font-bold w-10 text-right"><?= $item['progress'] ?>%</span>
|
||||
<div class="flex space-x-1">
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 25)" class="px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white cursor-pointer font-semibold transition">25%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 50)" class="px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white cursor-pointer font-semibold transition">50%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 100)" class="px-2 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/15 text-[10px] text-indigo-700 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-bold cursor-pointer transition">100%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 25)" class="px-1.5 py-0.5 rounded bg-slate-800 text-[10px] text-slate-400 hover:text-white">25%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 50)" class="px-1.5 py-0.5 rounded bg-slate-800 text-[10px] text-slate-400 hover:text-white">50%</button>
|
||||
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 100)" class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-[10px] text-emerald-400 hover:text-emerald-300 font-bold">100%</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deadline & Creator -->
|
||||
<div class="flex items-center space-x-3 text-slate-500 text-[11px]">
|
||||
<?php if (!empty($item['due_date'])): ?>
|
||||
<span class="flex items-center space-x-1 text-slate-700 dark:text-slate-300 font-semibold">
|
||||
<i data-lucide="calendar" class="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span class="flex items-center space-x-1 text-slate-300">
|
||||
<i data-lucide="calendar" class="w-3.5 h-3.5 text-emerald-400"></i>
|
||||
<span>Deadline: <?= date('d-m-Y', strtotime($item['due_date'])) ?></span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
@@ -245,33 +194,33 @@ unset($tm);
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nieuw Roadmap Item Handmatig Toevoegen -->
|
||||
<div id="newItemModal" onclick="if(event.target === this) this.classList.add('hidden')" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="map" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<div id="newItemModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full">
|
||||
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
|
||||
<h3 class="text-base font-bold text-white flex items-center space-x-2">
|
||||
<i data-lucide="map" class="w-4 h-4 text-emerald-400"></i>
|
||||
<span>Nieuw Roadmap Item Toevoegen</span>
|
||||
</h3>
|
||||
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="text-slate-400 hover:text-white">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Titel / Doel</label>
|
||||
<input type="text" id="manualTitle" placeholder="Bijv. 2FA Authenticatie afronden" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Titel / Doel</label>
|
||||
<input type="text" id="manualTitle" placeholder="Bijv. 2FA Authenticatie afronden" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beschrijving / Specificatie</label>
|
||||
<textarea id="manualDescription" rows="3" placeholder="Wat houdt deze milestone of feature in?" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Beschrijving / Specificatie</label>
|
||||
<textarea id="manualDescription" rows="3" placeholder="Wat houdt deze milestone of feature in?" class="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white text-xs placeholder-slate-500 focus:outline-none resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Kwartaal</label>
|
||||
<select id="manualQuarter" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Kwartaal</label>
|
||||
<select id="manualQuarter" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
|
||||
<option value="Q1">Q1</option>
|
||||
<option value="Q2">Q2</option>
|
||||
<option value="Q3" selected>Q3</option>
|
||||
@@ -279,8 +228,8 @@ unset($tm);
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
||||
<select id="manualType" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Type</label>
|
||||
<select id="manualType" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
|
||||
<option value="FEATURE" selected>Feature</option>
|
||||
<option value="EPIC">Epic</option>
|
||||
<option value="MILESTONE">Mijlpaal</option>
|
||||
@@ -291,8 +240,8 @@ unset($tm);
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
|
||||
<select id="manualPriority" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Prioriteit</label>
|
||||
<select id="manualPriority" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
|
||||
<option value="CRITICAL">Kritiek</option>
|
||||
<option value="HIGH" selected>Hoog</option>
|
||||
<option value="MEDIUM">Normaal</option>
|
||||
@@ -300,21 +249,21 @@ unset($tm);
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Jira Issue Key (optioneel)</label>
|
||||
<input type="text" id="manualJiraKey" placeholder="Bijv. FOC-101" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Jira Issue Key (optioneel)</label>
|
||||
<input type="text" id="manualJiraKey" placeholder="Bijv. FOC-101" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-white text-xs font-mono focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum</label>
|
||||
<input type="date" id="manualDueDate" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Streefdatum</label>
|
||||
<input type="date" id="manualDueDate" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-white text-xs focus:outline-none">
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button onclick="submitManualItem()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button onclick="submitManualItem()" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition">
|
||||
Toevoegen aan Roadmap
|
||||
</button>
|
||||
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
@@ -322,236 +271,6 @@ unset($tm);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Roadmap Item Bewerken -->
|
||||
<div id="editRoadmapModal" onclick="if(event.target === this) closeEditRoadmapModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Roadmap Item Bewerken</span>
|
||||
</h3>
|
||||
<button onclick="closeEditRoadmapModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="editRoadmapId">
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Titel / Doel</label>
|
||||
<input type="text" id="editRoadmapTitle" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beschrijving</label>
|
||||
<textarea id="editRoadmapDescription" rows="3" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Kwartaal</label>
|
||||
<select id="editRoadmapQuarter" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="Q1">Q1</option>
|
||||
<option value="Q2">Q2</option>
|
||||
<option value="Q3">Q3</option>
|
||||
<option value="Q4">Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
||||
<select id="editRoadmapType" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="FEATURE">Feature</option>
|
||||
<option value="EPIC">Epic</option>
|
||||
<option value="MILESTONE">Mijlpaal</option>
|
||||
<option value="TASK">Taak</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
|
||||
<select id="editRoadmapPriority" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="CRITICAL">Kritiek</option>
|
||||
<option value="HIGH">Hoog</option>
|
||||
<option value="MEDIUM">Normaal</option>
|
||||
<option value="LOW">Laag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Status</label>
|
||||
<select id="editRoadmapStatus" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<option value="PLANNED">Gepland</option>
|
||||
<option value="IN_PROGRESS">In Uitvoering</option>
|
||||
<option value="REVIEW">In Review</option>
|
||||
<option value="COMPLETED">Voltooid</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Jira Issue Key</label>
|
||||
<input type="text" id="editRoadmapJiraKey" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum</label>
|
||||
<input type="date" id="editRoadmapDueDate" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button id="btnSaveEditRoadmap" onclick="submitEditRoadmapItem()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
|
||||
Opslaan
|
||||
</button>
|
||||
<button onclick="closeEditRoadmapModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Roadmap Item Toewijzen & Inplannen in Vrij Blok -->
|
||||
<div id="roadmapScheduleModal" onclick="if(event.target === this) closeRoadmapScheduleModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-8 h-8 rounded-xl bg-amber-500/20 text-amber-600 dark:text-amber-400 flex items-center justify-center">
|
||||
<i data-lucide="calendar-plus" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Inplannen in Agenda van Collega</h3>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400">Plant dit item direct in een vrij tijdblok van de collega</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="closeRoadmapScheduleModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="roadmapScheduleItemId">
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Item titel preview -->
|
||||
<div class="p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs">
|
||||
<span class="text-[10px] text-slate-400 font-bold uppercase tracking-wider block mb-0.5">Roadmap Item</span>
|
||||
<span id="roadmapScheduleTitlePreview" class="font-bold text-slate-800 dark:text-slate-200"></span>
|
||||
</div>
|
||||
|
||||
<!-- Collega Selector -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Toewijzen aan collega</label>
|
||||
<select id="roadmapAssigneeSelect" onchange="onRoadmapAssigneeChange()" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<?php foreach ($teamMembers as $tm): ?>
|
||||
<option value="<?= $tm['id'] ?>" data-has-outlook="<?= $tm['has_outlook'] ? '1' : '0' ?>" <?= $tm['id'] === $currentUserId ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($tm['name']) ?> (<?= $tm['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>) <?= $tm['has_outlook'] ? '— 📅 Outlook Actief' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Outlook melding -->
|
||||
<div id="roadmapAssignOutlookNotice" class="p-2 rounded-xl bg-sky-50 dark:bg-sky-950/40 border border-sky-200 dark:border-sky-800/60 flex items-center space-x-2 text-[11px] text-sky-700 dark:text-sky-300">
|
||||
<i data-lucide="calendar-check" class="w-4 h-4 text-sky-500 flex-shrink-0"></i>
|
||||
<span>Deze collega heeft Microsoft Outlook gekoppeld. Er wordt gekeken naar echte vrije blokken.</span>
|
||||
</div>
|
||||
|
||||
<!-- Vrij Blok Zoeker -->
|
||||
<div class="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs">
|
||||
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
|
||||
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-500"></i>
|
||||
<span>Automatisch vrij blok vinden</span>
|
||||
</span>
|
||||
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden (09:00 - 17:00)</span>
|
||||
</div>
|
||||
<button type="button" id="btnRoadmapFindSlot" onclick="findFreeSlotForRoadmapAssignee()" class="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-xs shadow-xs transition flex items-center space-x-1.5 flex-shrink-0 cursor-pointer">
|
||||
<i data-lucide="search" class="w-3.5 h-3.5"></i>
|
||||
<span>Zoek vrij blok</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="roadmapFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-xs text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-500 flex-shrink-0"></i>
|
||||
<span id="roadmapFoundSlotText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Datum</label>
|
||||
<input type="date" id="roadmapPlanDate" value="<?= date('Y-m-d') ?>" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Starttijd</label>
|
||||
<input type="time" id="roadmapStartTime" value="10:00" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Duur</label>
|
||||
<select id="roadmapDurationMinutes" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="30">30 minuten</option>
|
||||
<option value="45">45 minuten</option>
|
||||
<option value="60" selected>1 uur</option>
|
||||
<option value="90">1.5 uur</option>
|
||||
<option value="120">2 uur</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center pt-5">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" id="roadmapSyncOutlook" checked class="rounded text-indigo-600 focus:ring-indigo-500">
|
||||
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200">Sync met Outlook 365</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<button id="btnSubmitRoadmapSchedule" onclick="submitRoadmapSchedule()" class="flex-1 bg-gradient-to-r from-indigo-600 to-indigo-500 hover:from-indigo-500 hover:to-indigo-400 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
<span>Inplannen in Agenda</span>
|
||||
</button>
|
||||
<button onclick="closeRoadmapScheduleModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechtermuisknop Contextmenu voor Roadmap Items -->
|
||||
<div id="roadmapContextMenu" class="hidden fixed z-50 w-56 bg-white dark:bg-slate-900 rounded-xl shadow-2xl border border-slate-200 dark:border-slate-800 py-1.5 text-xs text-slate-700 dark:text-slate-200">
|
||||
<button onclick="triggerRoadmapCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
|
||||
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
|
||||
<span>Item bewerken</span>
|
||||
</button>
|
||||
|
||||
<button onclick="triggerRoadmapCtxSchedule()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
|
||||
<i data-lucide="calendar-plus" class="w-4 h-4 text-amber-500"></i>
|
||||
<span>👤 Toewijzen & vrij blok inplannen</span>
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
|
||||
|
||||
<div class="px-3.5 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Verplaats naar Kwartaal</div>
|
||||
<div class="grid grid-cols-4 gap-1 px-3 py-1">
|
||||
<button onclick="triggerRoadmapCtxMoveQuarter('Q1')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q1</button>
|
||||
<button onclick="triggerRoadmapCtxMoveQuarter('Q2')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q2</button>
|
||||
<button onclick="triggerRoadmapCtxMoveQuarter('Q3')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q3</button>
|
||||
<button onclick="triggerRoadmapCtxMoveQuarter('Q4')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q4</button>
|
||||
</div>
|
||||
|
||||
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
|
||||
|
||||
<button onclick="triggerRoadmapCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
<span>Item verwijderen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function updateRoadmapStatus(id, status) {
|
||||
await fetch('/api/roadmap.php?action=update_item', {
|
||||
@@ -604,370 +323,7 @@ async function submitManualItem() {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag & Drop van Roadmap Items naar ander Kwartaal (Q1, Q2, Q3, Q4) ---
|
||||
let draggedRoadmapItemId = null;
|
||||
|
||||
function handleRoadmapItemDragStart(e, itemId) {
|
||||
draggedRoadmapItemId = itemId;
|
||||
try {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(itemId));
|
||||
} catch (err) {}
|
||||
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
if (el) el.classList.add('opacity-40', 'scale-[0.99]');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function handleRoadmapItemDragEnd(e) {
|
||||
if (draggedRoadmapItemId) {
|
||||
const el = document.getElementById(`roadmap-item-${draggedRoadmapItemId}`);
|
||||
if (el) el.classList.remove('opacity-40', 'scale-[0.99]');
|
||||
draggedRoadmapItemId = null;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.roadmap-quarter-tab').forEach(tab => {
|
||||
tab.classList.remove('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
|
||||
});
|
||||
}
|
||||
|
||||
function handleRoadmapQuarterDragOver(e) {
|
||||
if (!draggedRoadmapItemId) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const tab = e.currentTarget;
|
||||
if (tab && !tab.classList.contains('ring-2')) {
|
||||
tab.classList.add('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
|
||||
}
|
||||
}
|
||||
|
||||
function handleRoadmapQuarterDragLeave(e) {
|
||||
const tab = e.currentTarget;
|
||||
if (tab && !tab.contains(e.relatedTarget)) {
|
||||
tab.classList.remove('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoadmapQuarterDrop(e, targetQuarter) {
|
||||
e.preventDefault();
|
||||
const tab = e.currentTarget;
|
||||
if (tab) {
|
||||
tab.classList.remove('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
|
||||
}
|
||||
|
||||
const itemId = draggedRoadmapItemId || parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||
draggedRoadmapItemId = null;
|
||||
|
||||
if (itemId) {
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
if (el) el.classList.remove('opacity-40', 'scale-[0.99]', 'pointer-events-none');
|
||||
}
|
||||
document.querySelectorAll('[id^="roadmap-item-"].opacity-40').forEach(el => {
|
||||
el.classList.remove('opacity-40', 'scale-[0.99]', 'pointer-events-none');
|
||||
});
|
||||
|
||||
if (!itemId || !targetQuarter) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/roadmap.php?action=update_item', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ id: itemId, quarter: targetQuarter })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Kon item niet verplaatsen');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fout bij verplaatsen roadmap item:', err);
|
||||
alert('Netwerkfout bij verplaatsen');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rechtermuisknop Contextmenu & Acties voor Roadmap Items ---
|
||||
let activeRoadmapCtxItemId = null;
|
||||
|
||||
function openRoadmapContextMenu(e, itemId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
activeRoadmapCtxItemId = itemId;
|
||||
const menu = document.getElementById('roadmapContextMenu');
|
||||
if (!menu) return;
|
||||
|
||||
menu.classList.remove('hidden');
|
||||
const x = Math.min(e.clientX, window.innerWidth - 240);
|
||||
const y = Math.min(e.clientY, window.innerHeight - 240);
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeRoadmapContextMenu() {
|
||||
const menu = document.getElementById('roadmapContextMenu');
|
||||
if (menu) menu.classList.add('hidden');
|
||||
activeRoadmapCtxItemId = null;
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#roadmapContextMenu')) {
|
||||
closeRoadmapContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
function triggerRoadmapCtxEdit() {
|
||||
if (!activeRoadmapCtxItemId) return;
|
||||
const itemId = activeRoadmapCtxItemId;
|
||||
closeRoadmapContextMenu();
|
||||
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
if (!el) return;
|
||||
|
||||
document.getElementById('editRoadmapId').value = itemId;
|
||||
document.getElementById('editRoadmapTitle').value = el.getAttribute('data-title') || '';
|
||||
document.getElementById('editRoadmapDescription').value = el.getAttribute('data-description') || '';
|
||||
document.getElementById('editRoadmapQuarter').value = el.getAttribute('data-quarter') || 'Q3';
|
||||
document.getElementById('editRoadmapType').value = el.getAttribute('data-type') || 'FEATURE';
|
||||
document.getElementById('editRoadmapPriority').value = el.getAttribute('data-priority') || 'HIGH';
|
||||
document.getElementById('editRoadmapStatus').value = el.getAttribute('data-status') || 'PLANNED';
|
||||
document.getElementById('editRoadmapDueDate').value = el.getAttribute('data-due-date') || '';
|
||||
document.getElementById('editRoadmapJiraKey').value = el.getAttribute('data-jira-key') || '';
|
||||
|
||||
const modal = document.getElementById('editRoadmapModal');
|
||||
modal.classList.remove('hidden');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
document.getElementById('editRoadmapTitle')?.focus();
|
||||
}
|
||||
|
||||
function closeEditRoadmapModal() {
|
||||
document.getElementById('editRoadmapModal')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function submitEditRoadmapItem() {
|
||||
const id = parseInt(document.getElementById('editRoadmapId').value, 10);
|
||||
const title = document.getElementById('editRoadmapTitle').value.trim();
|
||||
const description = document.getElementById('editRoadmapDescription').value.trim();
|
||||
const quarter = document.getElementById('editRoadmapQuarter').value;
|
||||
const type = document.getElementById('editRoadmapType').value;
|
||||
const priority = document.getElementById('editRoadmapPriority').value;
|
||||
const status = document.getElementById('editRoadmapStatus').value;
|
||||
const due_date = document.getElementById('editRoadmapDueDate').value;
|
||||
const jira_key = document.getElementById('editRoadmapJiraKey').value.trim();
|
||||
|
||||
if (!title) {
|
||||
alert('Titel is verplicht');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnSaveEditRoadmap');
|
||||
btn.disabled = true;
|
||||
btn.innerText = 'Opslaan...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/roadmap.php?action=update_item', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
quarter,
|
||||
type,
|
||||
priority,
|
||||
status,
|
||||
due_date,
|
||||
jira_key
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
closeEditRoadmapModal();
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Kon item niet opslaan');
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Netwerkfout bij opslaan');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerText = 'Opslaan';
|
||||
}
|
||||
}
|
||||
|
||||
function triggerRoadmapCtxSchedule() {
|
||||
if (!activeRoadmapCtxItemId) return;
|
||||
const itemId = activeRoadmapCtxItemId;
|
||||
closeRoadmapContextMenu();
|
||||
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
if (!el) return;
|
||||
|
||||
document.getElementById('roadmapScheduleItemId').value = itemId;
|
||||
document.getElementById('roadmapScheduleTitlePreview').innerText = el.getAttribute('data-title') || 'Roadmap item';
|
||||
document.getElementById('roadmapFoundSlotNotice').classList.add('hidden');
|
||||
|
||||
onRoadmapAssigneeChange();
|
||||
|
||||
const modal = document.getElementById('roadmapScheduleModal');
|
||||
modal.classList.remove('hidden');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
setTimeout(() => {
|
||||
findFreeSlotForRoadmapAssignee();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function closeRoadmapScheduleModal() {
|
||||
document.getElementById('roadmapScheduleModal')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function onRoadmapAssigneeChange() {
|
||||
const sel = document.getElementById('roadmapAssigneeSelect');
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
const notice = document.getElementById('roadmapAssignOutlookNotice');
|
||||
if (opt && opt.getAttribute('data-has-outlook') === '1') {
|
||||
notice.classList.remove('hidden');
|
||||
} else {
|
||||
notice.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function findFreeSlotForRoadmapAssignee() {
|
||||
const sel = document.getElementById('roadmapAssigneeSelect');
|
||||
const targetUserId = sel.value ? parseInt(sel.value, 10) : <?= $currentUserId ?>;
|
||||
const duration = parseInt(document.getElementById('roadmapDurationMinutes').value, 10) || 60;
|
||||
|
||||
const btn = document.getElementById('btnRoadmapFindSlot');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Zoeken...</span>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/planner.php?action=find_next_free_slot', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
duration_minutes: duration,
|
||||
target_user_id: targetUserId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.slot) {
|
||||
const slot = data.slot;
|
||||
document.getElementById('roadmapPlanDate').value = slot.date;
|
||||
document.getElementById('roadmapStartTime').value = slot.start;
|
||||
|
||||
const notice = document.getElementById('roadmapFoundSlotNotice');
|
||||
const noticeText = document.getElementById('roadmapFoundSlotText');
|
||||
noticeText.innerHTML = `Vrij agendablok gevonden: <strong>${slot.formatted_date}</strong> om <strong>${slot.start} – ${slot.end}</strong>`;
|
||||
notice.classList.remove('hidden');
|
||||
} else {
|
||||
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen werktijden.');
|
||||
}
|
||||
} catch(err) {
|
||||
alert('Netwerkfout bij zoeken van vrij agendablok: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRoadmapSchedule() {
|
||||
const itemId = parseInt(document.getElementById('roadmapScheduleItemId').value, 10);
|
||||
const assigneeId = document.getElementById('roadmapAssigneeSelect').value ? parseInt(document.getElementById('roadmapAssigneeSelect').value, 10) : null;
|
||||
const planDate = document.getElementById('roadmapPlanDate').value;
|
||||
const startTime = document.getElementById('roadmapStartTime').value;
|
||||
const durationMinutes = parseInt(document.getElementById('roadmapDurationMinutes').value, 10) || 60;
|
||||
const syncOutlook = document.getElementById('roadmapSyncOutlook').checked;
|
||||
|
||||
const el = document.getElementById(`roadmap-item-${itemId}`);
|
||||
const title = el ? el.getAttribute('data-title') : 'Roadmap Item';
|
||||
|
||||
const btn = document.getElementById('btnSubmitRoadmapSchedule');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Inplannen...</span>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
try {
|
||||
// Maak agenda taak aan via api/planner.php
|
||||
const res = await fetch('/api/planner.php?action=add_task', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
date: planDate,
|
||||
title: title,
|
||||
start_time: startTime,
|
||||
duration: durationMinutes,
|
||||
type: 'PROJECT',
|
||||
priority: 'MEDIUM',
|
||||
sync_to_microsoft: syncOutlook,
|
||||
target_user_id: assigneeId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
closeRoadmapScheduleModal();
|
||||
let msg = `Roadmap item is succesvol ingepland op ${planDate} van ${startTime} tot ${data.task?.end_time || ''}!`;
|
||||
if (data.outlook_message) {
|
||||
msg += `\n${data.outlook_message}`;
|
||||
}
|
||||
alert(msg);
|
||||
} else {
|
||||
alert(data.error || 'Kon afspraak niet inplannen');
|
||||
}
|
||||
} catch(err) {
|
||||
alert('Netwerkfout bij inplannen: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerRoadmapCtxMoveQuarter(targetQuarter) {
|
||||
if (!activeRoadmapCtxItemId) return;
|
||||
const itemId = activeRoadmapCtxItemId;
|
||||
closeRoadmapContextMenu();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/roadmap.php?action=update_item', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ id: itemId, quarter: targetQuarter })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Kon item niet verplaatsen');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Netwerkfout bij verplaatsen item');
|
||||
}
|
||||
}
|
||||
|
||||
function triggerRoadmapCtxDelete() {
|
||||
if (!activeRoadmapCtxItemId) return;
|
||||
const itemId = activeRoadmapCtxItemId;
|
||||
closeRoadmapContextMenu();
|
||||
deleteRoadmapItem(itemId);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('newItemModal');
|
||||
if (modal && !modal.classList.contains('hidden')) modal.classList.add('hidden');
|
||||
closeEditRoadmapModal();
|
||||
closeRoadmapScheduleModal();
|
||||
closeRoadmapContextMenu();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Fociz Service Worker
|
||||
* Beheert desktop push notificaties en click events voor agenda-afspraken.
|
||||
*/
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// Behandel klik op een notificatie
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const targetUrl = (event.notification.data && event.notification.data.url)
|
||||
? event.notification.data.url
|
||||
: '/planner.php';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
// Als er al een Fociz tabblad open staat, breng die naar voren
|
||||
for (let i = 0; i < windowClients.length; i++) {
|
||||
const client = windowClients[i];
|
||||
if ('focus' in client) {
|
||||
if (targetUrl.startsWith('http') && !targetUrl.includes(self.location.host)) {
|
||||
// Externe meeting link (zoals Teams): open in nieuw tabblad
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(targetUrl);
|
||||
}
|
||||
} else {
|
||||
client.navigate(targetUrl);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Geen tabblad open: open een nieuw venster
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(targetUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,24 +3,16 @@
|
||||
* Fociz Team & Uitnodigingen Beheer (Enkel voor Admin)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
require_once __DIR__ . '/includes/mailer.php';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
$adminUser = require_admin();
|
||||
$currentUser = $adminUser;
|
||||
$db = get_db_connection();
|
||||
|
||||
$message = null;
|
||||
$generatedLink = null;
|
||||
$error = null;
|
||||
|
||||
// POST acties afhandelen met CSRF-bescherming
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
$csrfToken = $_POST['csrf_token'] ?? null;
|
||||
|
||||
if (!verify_csrf_token($csrfToken)) {
|
||||
$error = "Beveiligingsfout: CSRF-token ongeldig of verlopen. Ververs de pagina en probeer opnieuw.";
|
||||
} elseif ($action === 'create_invite') {
|
||||
// Uitnodiging aanmaken
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_invite') {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$role = in_array($_POST['role'] ?? '', ['ADMIN', 'MEMBER']) ? $_POST['role'] : 'MEMBER';
|
||||
|
||||
@@ -36,122 +28,77 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$token = create_invite($adminUser['id'], $email, $role);
|
||||
$baseUrl = rtrim(APP_URL, '/');
|
||||
$generatedLink = "{$baseUrl}/auth/invite.php?token={$token}";
|
||||
$message = "Uitnodigingslink succesvol aangemaakt!";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verstuur de gestylede uitnodigingsmail direct via SMTP
|
||||
$mailResult = FocizMailer::sendInvitationEmail($email, $adminUser['name'], $role, $generatedLink);
|
||||
fociz_log_audit((int)$adminUser['id'], 'USER_INVITED', 'invite', null, ['email' => $email, 'role' => $role], 'SUCCESS', $adminUser['email']);
|
||||
if ($mailResult['success']) {
|
||||
$message = "Uitnodigingslink succesvol aangemaakt én per e-mail verzonden naar {$email}!";
|
||||
} else {
|
||||
$message = "Uitnodiging aangemaakt! Let op: de e-mail kon niet worden verzonden (" . htmlspecialchars($mailResult['error'] ?? 'serverfout') . "). U kunt de link handmatig kopiëren.";
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($action === 'resend_invite') {
|
||||
$inviteId = (int)($_POST['invite_id'] ?? 0);
|
||||
$stmtInv = $db->prepare("SELECT * FROM invites WHERE id = ? AND used_at IS NULL AND expires_at > NOW()");
|
||||
$stmtInv->execute([$inviteId]);
|
||||
$inv = $stmtInv->fetch();
|
||||
if ($inv) {
|
||||
$baseUrl = rtrim(APP_URL, '/');
|
||||
$link = "{$baseUrl}/auth/invite.php?token={$inv['token']}";
|
||||
$mailResult = FocizMailer::sendInvitationEmail($inv['email'], $adminUser['name'], $inv['role'], $link);
|
||||
fociz_log_audit((int)$adminUser['id'], 'INVITE_RESENT', 'invite', $inviteId, ['email' => $inv['email'], 'role' => $inv['role']], 'SUCCESS', $adminUser['email']);
|
||||
if ($mailResult['success']) {
|
||||
$message = "Uitnodiging succesvol opnieuw per e-mail verzonden naar {$inv['email']}!";
|
||||
} else {
|
||||
$error = "Fout bij opnieuw verzenden van uitnodiging: " . ($mailResult['error'] ?? 'Onbekende fout');
|
||||
}
|
||||
} else {
|
||||
$error = "Uitnodiging niet gevonden of reeds verlopen/geactiveerd.";
|
||||
}
|
||||
} elseif ($action === 'delete_invite') {
|
||||
$inviteId = (int)($_POST['invite_id'] ?? 0);
|
||||
// Uitnodiging intrekken
|
||||
if (isset($_GET['delete_invite'])) {
|
||||
$inviteId = (int)$_GET['delete_invite'];
|
||||
$stmtDel = $db->prepare("DELETE FROM invites WHERE id = ?");
|
||||
$stmtDel->execute([$inviteId]);
|
||||
fociz_log_audit((int)$adminUser['id'], 'INVITE_REVOKED', 'invite', $inviteId, [], 'SUCCESS', $adminUser['email']);
|
||||
header('Location: /users.php');
|
||||
exit;
|
||||
} elseif ($action === 'delete_user') {
|
||||
$delUserId = (int)($_POST['user_id'] ?? 0);
|
||||
if ($delUserId !== (int)$adminUser['id']) {
|
||||
$stmtUserToDel = $db->prepare("SELECT email, name FROM users WHERE id = ?");
|
||||
$stmtUserToDel->execute([$delUserId]);
|
||||
$delUser = $stmtUserToDel->fetch();
|
||||
}
|
||||
|
||||
// Gebruiker verwijderen
|
||||
if (isset($_GET['delete_user'])) {
|
||||
$delUserId = (int)$_GET['delete_user'];
|
||||
if ($delUserId !== (int)$adminUser['id']) {
|
||||
$stmtDelUser = $db->prepare("DELETE FROM users WHERE id = ?");
|
||||
$stmtDelUser->execute([$delUserId]);
|
||||
try {
|
||||
$db->prepare("DELETE FROM user_sessions WHERE user_id = ?")->execute([$delUserId]);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
fociz_log_audit((int)$adminUser['id'], 'USER_DELETED', 'user', $delUserId, ['deleted_email' => $delUser['email'] ?? null, 'deleted_name' => $delUser['name'] ?? null], 'SUCCESS', $adminUser['email']);
|
||||
}
|
||||
header('Location: /users.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Haal alle gebruikers op
|
||||
$users = [];
|
||||
try {
|
||||
$users = $db->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen gebruikers: " . $e->getMessage());
|
||||
}
|
||||
$users = $db->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll();
|
||||
|
||||
// Haal alle uitnodigingen op
|
||||
$invites = [];
|
||||
try {
|
||||
$invites = $db->query("
|
||||
$invites = $db->query("
|
||||
SELECT i.*, u.name as inviter_name
|
||||
FROM invites i
|
||||
JOIN users u ON i.created_by_id = u.id
|
||||
ORDER BY i.created_at DESC
|
||||
")->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen uitnodigingen: " . $e->getMessage());
|
||||
ensure_tables_exist($db);
|
||||
}
|
||||
|
||||
$pageTitle = 'Team & Uitnodigingen — Fociz';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
")->fetchAll();
|
||||
?>
|
||||
|
||||
<div class="space-y-6 max-w-7xl mx-auto pb-12">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="fociz-card p-6 sm:p-7">
|
||||
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
|
||||
<div class="flex items-center space-x-2 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-1">
|
||||
<i data-lucide="shield" class="w-4 h-4"></i>
|
||||
<span>Beheerderspaneel</span>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
Team & Uitnodigingen
|
||||
</h1>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
||||
Beheer collega's, rollen en uitnodigingslinks voor toegang tot de Fociz omgeving.
|
||||
<p class="text-slate-400 text-xs sm:text-sm mt-1">
|
||||
Fociz is strikt afgesloten: nieuwe collega's kunnen enkel toegang krijgen na het ontvangen van een uitnodigingslink en verplichte 2FA-activatie.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="p-4 rounded-2xl bg-red-500/10 border border-red-500/30 text-red-700 dark:text-red-400 text-xs font-semibold flex items-center space-x-2">
|
||||
<div class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-xs font-semibold flex items-center space-x-2">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
|
||||
<span><?= htmlspecialchars($error) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($message && $generatedLink): ?>
|
||||
<div class="p-5 rounded-2xl bg-teal-50/80 dark:bg-teal-950/30 border border-teal-200 dark:border-teal-800/60 text-slate-900 dark:text-white space-y-2 shadow-xs transition">
|
||||
<div class="flex items-center space-x-2 text-teal-700 dark:text-teal-400 font-bold text-xs">
|
||||
<div class="p-5 rounded-2xl bg-emerald-950/40 border border-emerald-500/40 text-white space-y-2 shadow-xl">
|
||||
<div class="flex items-center space-x-2 text-emerald-400 font-bold text-xs">
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
|
||||
<span><?= htmlspecialchars($message) ?></span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-300">
|
||||
<p class="text-xs text-slate-300">
|
||||
Kopieer onderstaande beveiligde registratielink en stuur deze naar de collega:
|
||||
</p>
|
||||
<div class="flex items-center space-x-2 bg-white dark:bg-[#0c0f17] p-2.5 rounded-xl border border-teal-200 dark:border-slate-800">
|
||||
<input type="text" readonly id="inviteLinkInput" value="<?= htmlspecialchars($generatedLink) ?>" class="bg-transparent text-teal-700 dark:text-teal-300 font-mono text-xs flex-1 outline-none">
|
||||
<button onclick="copyInviteLink()" class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition flex items-center space-x-1 cursor-pointer">
|
||||
<div class="flex items-center space-x-2 bg-slate-950 p-2.5 rounded-xl border border-slate-800">
|
||||
<input type="text" readonly id="inviteLinkInput" value="<?= htmlspecialchars($generatedLink) ?>" class="bg-transparent text-emerald-300 font-mono text-xs flex-1 outline-none">
|
||||
<button onclick="copyInviteLink()" class="px-3 py-1.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold text-xs rounded-lg transition flex items-center space-x-1">
|
||||
<i data-lucide="copy" class="w-3.5 h-3.5"></i>
|
||||
<span id="copyBtnText">Kopiëren</span>
|
||||
</button>
|
||||
@@ -160,31 +107,30 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Uitnodiging Genereren Kaart -->
|
||||
<div class="fociz-card p-6 sm:p-7">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white mb-4 flex items-center space-x-2">
|
||||
<i data-lucide="user-plus" class="w-5 h-5 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
|
||||
<h3 class="text-base font-bold text-white mb-4 flex items-center space-x-2">
|
||||
<i data-lucide="user-plus" class="w-5 h-5 text-emerald-400"></i>
|
||||
<span>Nieuwe Collega Uitnodigen</span>
|
||||
</h3>
|
||||
|
||||
<form method="POST" action="/users.php" class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
|
||||
<input type="hidden" name="action" value="create_invite">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="md:col-span-6">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres van collega</label>
|
||||
<input type="email" name="email" required placeholder="collega@bedrijf.nl" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-xs shadow-2xs">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">E-mailadres van collega</label>
|
||||
<input type="email" name="email" required placeholder="collega@bedrijf.nl" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 text-xs">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Rol</label>
|
||||
<select name="role" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Rol</label>
|
||||
<select name="role" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-white text-xs focus:outline-none">
|
||||
<option value="MEMBER">Collega (Standaard)</option>
|
||||
<option value="ADMIN">Mede-beheerder</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<button type="submit" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<button type="submit" class="w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<i data-lucide="link" class="w-4 h-4"></i>
|
||||
<span>Genereer Link</span>
|
||||
</button>
|
||||
@@ -193,17 +139,17 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
|
||||
<!-- Actieve Gebruikers Tabel -->
|
||||
<div class="fociz-card p-6 sm:p-7 space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider flex items-center space-x-2">
|
||||
<i data-lucide="users" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center space-x-2">
|
||||
<i data-lucide="users" class="w-4 h-4 text-emerald-400"></i>
|
||||
<span>Geregistreerde Teamleden (<?= count($users) ?>)</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||
<thead class="bg-slate-50 dark:bg-[#0c0f17] text-slate-500 dark:text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-200 dark:border-slate-800">
|
||||
<table class="w-full text-left text-xs text-slate-300">
|
||||
<thead class="bg-slate-950/60 text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-800">
|
||||
<tr>
|
||||
<th class="p-3">Naam & E-mail</th>
|
||||
<th class="p-3">Rol</th>
|
||||
@@ -212,55 +158,34 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<th class="p-3 text-right">Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/60">
|
||||
<tbody class="divide-y divide-slate-800/60">
|
||||
<?php foreach ($users as $u): ?>
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
|
||||
<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="p-3">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 overflow-hidden shadow-2xs">
|
||||
<?php if (!empty($u['avatar_url'])): ?>
|
||||
<img src="<?= htmlspecialchars($u['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-[10px] object-cover bg-white dark:bg-slate-900" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
|
||||
<div class="hidden w-full h-full rounded-[10px] bg-white dark:bg-slate-900 flex items-center justify-center font-bold text-xs text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($u['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="w-full h-full rounded-[10px] bg-white dark:bg-slate-900 flex items-center justify-center font-bold text-xs text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($u['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($u['name']) ?></div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 font-mono"><?= htmlspecialchars($u['email']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-bold text-white"><?= htmlspecialchars($u['name']) ?></div>
|
||||
<div class="text-[11px] text-slate-400 font-mono"><?= htmlspecialchars($u['email']) ?></div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $u['role'] === 'ADMIN' ? 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300' ?>">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $u['role'] === 'ADMIN' ? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30' : 'bg-slate-800 text-slate-300' ?>">
|
||||
<?= $u['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60 text-[10px] font-medium flex items-center space-x-1 w-max">
|
||||
<span class="px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[10px] font-medium flex items-center space-x-1 w-max">
|
||||
<i data-lucide="shield-check" class="w-3 h-3"></i>
|
||||
<span>2FA Actief</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3 text-slate-500 dark:text-slate-400">
|
||||
<td class="p-3 text-slate-400">
|
||||
<?= date('d-m-Y', strtotime($u['created_at'])) ?>
|
||||
</td>
|
||||
<td class="p-3 text-right">
|
||||
<?php if ($u['id'] !== $adminUser['id']): ?>
|
||||
<form method="POST" action="/users.php" class="inline" onsubmit="return confirm('Weet u zeker dat u deze gebruiker wilt verwijderen?')">
|
||||
<input type="hidden" name="action" value="delete_user">
|
||||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" title="Gebruiker verwijderen" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer bg-transparent border-0">
|
||||
<a href="/users.php?delete_user=<?= $u['id'] ?>" onclick="return confirm('Gebruiker verwijderen?')" class="text-slate-500 hover:text-red-400 transition p-1">
|
||||
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
|
||||
</button>
|
||||
</form>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="text-[10px] text-slate-400 dark:text-slate-600 font-medium">Uzelf</span>
|
||||
<span class="text-[10px] text-slate-600 font-medium">Uzelf</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -271,17 +196,17 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</div>
|
||||
|
||||
<!-- Uitstaande Uitnodigingen Tabel -->
|
||||
<div class="fociz-card p-6 sm:p-7 space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider flex items-center space-x-2">
|
||||
<i data-lucide="mail" class="w-4 h-4 text-amber-500"></i>
|
||||
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center space-x-2">
|
||||
<i data-lucide="mail" class="w-4 h-4 text-amber-400"></i>
|
||||
<span>Uitnodigingen Status (<?= count($invites) ?>)</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
||||
<thead class="bg-slate-50 dark:bg-[#0c0f17] text-slate-500 dark:text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-200 dark:border-slate-800">
|
||||
<table class="w-full text-left text-xs text-slate-300">
|
||||
<thead class="bg-slate-950/60 text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-800">
|
||||
<tr>
|
||||
<th class="p-3">Uitgenodigd E-mailadres</th>
|
||||
<th class="p-3">Rol</th>
|
||||
@@ -290,53 +215,31 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<th class="p-3 text-right">Actie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/60">
|
||||
<tbody class="divide-y divide-slate-800/60">
|
||||
<?php if (empty($invites)): ?>
|
||||
<tr><td colspan="5" class="p-4 text-center text-slate-400 dark:text-slate-500">Nog geen uitnodigingen verstuurd.</td></tr>
|
||||
<tr><td colspan="5" class="p-4 text-center text-slate-500">Nog geen uitnodigingen verstuurd.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($invites as $inv):
|
||||
$isUsed = !empty($inv['used_at']);
|
||||
$isExpired = strtotime($inv['expires_at']) < time();
|
||||
?>
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
|
||||
<td class="p-3 font-mono text-slate-900 dark:text-white"><?= htmlspecialchars($inv['email']) ?></td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $inv['role'] === 'ADMIN' ? 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300' ?>">
|
||||
<?= $inv['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
|
||||
</span>
|
||||
</td>
|
||||
<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="p-3 font-mono"><?= htmlspecialchars($inv['email']) ?></td>
|
||||
<td class="p-3"><?= htmlspecialchars($inv['role']) ?></td>
|
||||
<td class="p-3">
|
||||
<?php if ($isUsed): ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-teal-50 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60">Geaccepteerd</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400">Geaccepteerd</span>
|
||||
<?php elseif ($isExpired): ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/15 text-red-700 dark:text-red-400 border border-red-500/30">Verlopen</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-400">Verlopen</span>
|
||||
<?php else: ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/30">In afwachting</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400">In afwachting</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="p-3 text-slate-500 dark:text-slate-400"><?= date('d-m-Y H:i', strtotime($inv['expires_at'])) ?></td>
|
||||
<td class="p-3 text-slate-400"><?= date('d-m-Y H:i', strtotime($inv['expires_at'])) ?></td>
|
||||
<td class="p-3 text-right">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<?php if (!$isUsed && !$isExpired): ?>
|
||||
<form method="POST" action="/users.php" class="inline">
|
||||
<input type="hidden" name="action" value="resend_invite">
|
||||
<input type="hidden" name="invite_id" value="<?= (int)$inv['id'] ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" title="Uitnodigingsmail opnieuw verzenden" class="inline-flex items-center space-x-1 px-2 py-1 bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/40 dark:hover:bg-indigo-900/60 text-indigo-600 dark:text-indigo-400 rounded-lg text-[11px] font-medium transition cursor-pointer border-0">
|
||||
<i data-lucide="mail" class="w-3.5 h-3.5"></i>
|
||||
<span>Opnieuw mailen</span>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<form method="POST" action="/users.php" class="inline" onsubmit="return confirm('Weet u zeker dat u deze uitnodiging wilt intrekken?')">
|
||||
<input type="hidden" name="action" value="delete_invite">
|
||||
<input type="hidden" name="invite_id" value="<?= (int)$inv['id'] ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" title="Uitnodiging intrekken" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer bg-transparent border-0">
|
||||
<a href="/users.php?delete_invite=<?= $inv['id'] ?>" onclick="return confirm('Uitnodiging intrekken?')" class="text-slate-500 hover:text-red-400 transition p-1">
|
||||
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
@@ -360,3 +263,4 @@ function copyInviteLink() {
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
|
||||