195 Commits

Author SHA1 Message Date
NiekRabelink 091de8bf95 Update composer.json 2026-10-21 16:12:01 +00:00
NiekRabelink 3312c08ea1 Update composer.json 2026-10-21 16:04:13 +00:00
NiekRabelink 39a41fa207 Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-10-21 17:06:40 +02:00
NiekRabelink 78369a44c2 Php version e.d. 2026-10-21 17:06:36 +02:00
NiekRabelink 07988b3e1d Rename block 2026-10-05 11:01:25 +02:00
NiekRabelink 0c466972c2 fix(env): lock production hostnames to live environment and live database 2026-10-05 10:39:17 +02:00
NiekRabelink dc6b55e4f9 Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-10-05 10:32:44 +02:00
NiekRabelink ef116be9b8 Simple todo 2026-10-05 10:32:30 +02:00
NiekRabelink 65d5a4d4a3 Merge branch 'main' into dev 2026-09-23 10:52:42 +02:00
NiekRabelink a4824bb440 fix(brainstorm): auto-queue and retry post-it generation with fallback starter cards 2026-09-23 10:52:29 +02:00
NiekRabelink e45f565365 Merge branch 'main' into dev (sync AI resiliency & fallbacks) 2026-09-23 10:50:26 +02:00
NiekRabelink 3dc09a11a7 fix(ai): prioritize flash models, prevent high-demand blocks, and add resilient brainstorm fallbacks 2026-09-23 10:49:52 +02:00
NiekRabelink 86041b665d Ensure all fallback AI models are tried before giving up 2026-09-23 10:34:18 +02:00
NiekRabelink f18f7d5794 Merge pull request 'Implement intelligent fallback for AI requests during high demand and rate limiting' (#98) from dev into main
Reviewed-on: #98
2026-09-23 08:24:46 +00:00
NiekRabelink 45600040ea Implement intelligent fallback for AI requests during high demand and rate limiting 2026-09-23 10:23:10 +02:00
NiekRabelink 660f196c60 Merge pull request 'Enhance IP verification process: add email resend functionality and improve error logging' (#97) from dev into main
Reviewed-on: #97
2026-09-23 07:22:40 +00:00
NiekRabelink 32974cdb31 Enhance IP verification process: add email resend functionality and improve error logging 2026-09-23 09:21:17 +02:00
NiekRabelink 8ddae4d883 Merge pull request 'Snelheid' (#96) from dev into main
Reviewed-on: #96
2026-09-16 11:54:05 +00:00
NiekRabelink d4e4b69e02 Snelheid 2026-09-16 13:53:54 +02:00
NiekRabelink 65f81d620c Merge pull request 'Change mail options' (#95) from dev into main
Reviewed-on: #95
2026-09-16 10:39:19 +00:00
NiekRabelink 0b5ca54db9 Change mail options 2026-09-16 12:39:06 +02:00
NiekRabelink 20d8f93f77 Merge pull request 'Fix speed and error' (#94) from dev into main
Reviewed-on: #94
2026-09-16 10:23:33 +00:00
NiekRabelink bda7b311e3 Fix speed and error 2026-09-16 12:22:27 +02:00
NiekRabelink 85850f47ad Merge pull request 'Dev' (#93) from dev into main
Reviewed-on: #93
2026-09-15 22:32:58 +00:00
NiekRabelink 45a883319a Outlook changes 2026-09-16 00:32:29 +02:00
NiekRabelink 460fa967ee Jira 28 2026-09-15 22:46:21 +02:00
NiekRabelink e9f54cbe72 Merge pull request 'Jira 27' (#92) from dev into main
Reviewed-on: #92
2026-09-15 20:39:10 +00:00
NiekRabelink 210ecc7b18 Jira 27 2026-09-15 22:36:59 +02:00
NiekRabelink 2d89e19114 Merge pull request 'Jira 26' (#91) from dev into main
Reviewed-on: #91
2026-09-15 20:32:23 +00:00
NiekRabelink 6bc351aaa4 Jira 26 2026-09-15 22:31:53 +02:00
NiekRabelink 1ad383d27b Merge pull request 'Jira 25' (#90) from dev into main
Reviewed-on: #90
2026-09-15 20:12:27 +00:00
NiekRabelink 8498d82e10 Jira 25 2026-09-15 22:12:14 +02:00
NiekRabelink a038d25489 Merge pull request 'Jira 24' (#89) from dev into main
Reviewed-on: #89
2026-09-15 20:05:01 +00:00
NiekRabelink 97d6085c9d Jira 24 2026-09-15 22:04:51 +02:00
NiekRabelink 4a9ba4d76a Merge pull request 'Fix 23' (#88) from dev into main
Reviewed-on: #88
2026-09-15 19:16:08 +00:00
NiekRabelink b800279cd2 Fix 23 2026-09-15 21:15:42 +02:00
NiekRabelink e08b304c38 Merge pull request 'Jira22' (#87) from dev into main
Reviewed-on: #87
2026-09-15 16:40:43 +00:00
NiekRabelink 687b929170 Jira22 2026-09-15 18:40:16 +02:00
NiekRabelink 830fdf1330 Merge pull request 'Filter changes' (#86) from dev into main
Reviewed-on: #86
2026-09-15 14:26:22 +00:00
NiekRabelink 2750fd0681 Filter changes 2026-09-15 16:26:00 +02:00
NiekRabelink d65463dab8 Merge pull request 'Jira repair' (#85) from dev into main
Reviewed-on: #85
2026-09-15 13:42:38 +00:00
NiekRabelink 8cff08f253 Jira repair 2026-09-15 15:42:23 +02:00
NiekRabelink 843b75334e Merge pull request 'Dev' (#84) from dev into main
Reviewed-on: #84
2026-09-15 13:00:29 +00:00
NiekRabelink 9cd457971b Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-09-15 14:49:35 +02:00
NiekRabelink 1358b6a7e9 Jira fix 2026-09-15 13:41:59 +02:00
NiekRabelink aa23ccdd73 Merge pull request 'Jira en admin aanpassingen' (#83) from dev into main
Reviewed-on: #83
2026-09-15 10:31:57 +00:00
NiekRabelink e3af7c7aa5 Jira en admin aanpassingen 2026-09-15 12:30:58 +02:00
NiekRabelink cb8246dba9 Merge pull request 'Jira aanpassingen' (#82) from dev into main
Reviewed-on: #82
2026-09-14 14:24:42 +00:00
NiekRabelink f992ca1ec9 Jira aanpassingen 2026-09-14 16:24:23 +02:00
NiekRabelink 5c4c53922b Merge pull request 'Header center' (#81) from dev into main
Reviewed-on: #81
2026-09-14 11:16:35 +00:00
NiekRabelink 7a85f612b1 Header center 2026-09-14 13:16:22 +02:00
NiekRabelink ee12d54350 Merge pull request 'Header redesign' (#80) from dev into main
Reviewed-on: #80
2026-09-14 10:15:33 +00:00
NiekRabelink e8a6667abd Header redesign 2026-09-14 12:15:18 +02:00
NiekRabelink 7e7f071f04 Merge pull request 'Dashboard' (#79) from dev into main
Reviewed-on: #79
2026-09-14 09:52:31 +00:00
NiekRabelink 3595d58763 Dashboard 2026-09-14 11:52:18 +02:00
NiekRabelink bf0d2f3bca Merge pull request 'Brainstorm huge refactor' (#78) from dev into main
Reviewed-on: #78
2026-09-14 09:11:25 +00:00
NiekRabelink 24fbfb9d82 Brainstorm huge refactor 2026-09-14 11:10:54 +02:00
NiekRabelink 6a4981744f Merge pull request 'Fix brainstorm delete and plan button' (#77) from dev into main
Reviewed-on: #77
2026-09-14 08:29:13 +00:00
NiekRabelink 5a31f0948a Fix brainstorm delete and plan button 2026-09-14 10:29:01 +02:00
NiekRabelink 740b5343c4 Merge pull request 'Dev' (#76) from dev into main
Reviewed-on: #76
2026-09-14 08:08:27 +00:00
NiekRabelink cd6071902d Fix plan button 2026-09-14 10:07:37 +02:00
NiekRabelink 32d27cd457 Auto synch outlook 2026-09-14 09:21:19 +02:00
NiekRabelink 31caf540a3 Smoothness 2026-09-12 22:28:03 +02:00
NiekRabelink 4eaae64eb6 Settings planner 2026-09-12 22:22:01 +02:00
NiekRabelink 6987cab4f8 Merge pull request 'Verbetering postit' (#75) from dev into main
Reviewed-on: #75
2026-09-12 18:17:15 +00:00
NiekRabelink 89b53f6428 Verbetering postit 2026-09-12 20:16:59 +02:00
NiekRabelink fd59cd69c1 Merge pull request 'Postit' (#74) from dev into main
Reviewed-on: #74
2026-09-12 18:13:19 +00:00
NiekRabelink 35fcde49c3 Postit 2026-09-12 20:12:48 +02:00
NiekRabelink 29874f184b Merge pull request 'Fix' (#73) from dev into main
Reviewed-on: #73
2026-09-12 16:13:16 +00:00
NiekRabelink f63ea740bf Fix 2026-09-12 18:12:30 +02:00
NiekRabelink 0bafe0afe9 Merge pull request 'Postits' (#72) from dev into main
Reviewed-on: #72
2026-09-12 15:24:09 +00:00
NiekRabelink 5f15eb67fe Postits 2026-09-12 17:23:52 +02:00
NiekRabelink 880c1bf9bb Merge pull request 'Dev' (#71) from dev into main
Reviewed-on: #71
2026-09-12 15:15:39 +00:00
NiekRabelink 94d6325693 Postits en editable items. 2026-09-12 17:14:47 +02:00
NiekRabelink 7c27949cc2 Brainstorm aanpassingen 2026-09-12 17:04:16 +02:00
NiekRabelink a0d99914e6 Merge pull request 'Dev nieuw' (#70) from dev into main
Reviewed-on: #70
2026-09-12 14:50:06 +00:00
NiekRabelink af80ffdb14 Dev nieuw 2026-09-12 16:49:51 +02:00
NiekRabelink 1c3541e602 Merge pull request 'Ai aanpassingen en slepen in planner blokken' (#69) from dev into main
Reviewed-on: #69
2026-09-12 12:24:36 +00:00
NiekRabelink 5e35f5152d Ai aanpassingen en slepen in planner blokken 2026-09-12 14:24:24 +02:00
NiekRabelink 845216e554 Merge pull request 'Brainstorm fix' (#68) from dev into main
Reviewed-on: #68
2026-09-12 11:45:39 +00:00
NiekRabelink 797aaa76c7 Brainstorm fix 2026-09-12 13:45:01 +02:00
NiekRabelink 0ad186e95f Merge pull request 'Dev' (#67) from dev into main
Reviewed-on: #67
2026-09-12 11:24:10 +00:00
NiekRabelink 8714038db2 Aanpassingen planner en ai 2026-09-12 13:24:00 +02:00
NiekRabelink bc33c0bd1a Icons 2026-09-12 13:14:38 +02:00
NiekRabelink 582ba9871f Merge pull request 'IT882' (#66) from dev into main
Reviewed-on: #66
2026-09-12 11:10:58 +00:00
NiekRabelink 32632dab91 IT882 2026-09-12 13:09:37 +02:00
NiekRabelink 9442dd7b38 Merge pull request 'AI fix' (#65) from dev into main
Reviewed-on: #65
2026-09-12 11:01:08 +00:00
NiekRabelink ab03552c29 AI fix 2026-09-12 12:57:11 +02:00
NiekRabelink 046d344003 Merge pull request 'Ai fix' (#64) from dev into main
Reviewed-on: #64
2026-09-12 10:51:38 +00:00
NiekRabelink 364fd776c4 Ai fix 2026-09-12 12:51:20 +02:00
NiekRabelink a1e9dd19ae Merge pull request 'Planner wijzigingen' (#63) from dev into main
Reviewed-on: #63
2026-09-12 10:50:24 +00:00
NiekRabelink a514eb6521 Planner wijzigingen 2026-09-12 12:49:58 +02:00
NiekRabelink d87a62e1f2 Merge pull request 'Error 500 fix' (#62) from dev into main
Reviewed-on: #62
2026-09-12 09:22:50 +00:00
NiekRabelink f620f20d35 Error 500 fix 2026-09-12 11:19:03 +02:00
NiekRabelink f389f67774 Merge pull request 'Fix' (#61) from dev into main
Reviewed-on: #61
2026-09-12 09:15:45 +00:00
NiekRabelink bb0bf658ed Fix 2026-09-12 11:12:31 +02:00
NiekRabelink a1f2937504 Merge pull request 'Aanpassing planner' (#60) from dev into main
Reviewed-on: #60
2026-09-12 09:04:16 +00:00
NiekRabelink 09ab527a93 Aanpassing planner 2026-09-12 11:04:02 +02:00
NiekRabelink 51658e0aa9 Merge pull request 'Timzone' (#59) from dev into main
Reviewed-on: #59
2026-09-12 08:57:25 +00:00
NiekRabelink 5a5456ffe0 Timzone 2026-09-12 10:57:11 +02:00
NiekRabelink 8e62e73d46 Merge pull request 'Workdays' (#58) from dev into main
Reviewed-on: #58
2026-09-12 08:52:13 +00:00
NiekRabelink bcd85f131b Workdays 2026-09-12 10:45:31 +02:00
NiekRabelink 41d3733f69 Merge pull request 'Last security check' (#57) from dev into main
Reviewed-on: #57
2026-09-12 08:26:59 +00:00
NiekRabelink 245d09e62c Last security check 2026-09-12 10:25:49 +02:00
NiekRabelink 8095a63f31 Merge pull request 'Session guard refresh 1s' (#56) from dev into main
Reviewed-on: #56
2026-09-12 07:59:59 +00:00
NiekRabelink 987bcf5684 Session guard refresh 1s 2026-09-12 09:59:36 +02:00
NiekRabelink 30be1dfee9 Merge pull request 'Dev' (#55) from dev into main
Reviewed-on: #55
2026-09-11 20:20:49 +00:00
NiekRabelink fc1a211c73 Security 2026-09-11 22:20:29 +02:00
NiekRabelink 38701bf06a Fix consistentie benamingen. 2026-09-11 22:14:50 +02:00
NiekRabelink ad06c67235 Merge pull request 'Dev' (#54) from dev into main
Reviewed-on: #54
2026-09-11 19:45:36 +00:00
NiekRabelink deba8463b8 Large refactor 2026-09-11 21:43:36 +02:00
NiekRabelink 7fe7c0f51a Planner changes 2026-09-11 17:30:18 +02:00
NiekRabelink 7461231879 Merge pull request 'feat(planner): add functionality to find and plan tasks in the next available time slot, including date selection and UI updates' (#53) from dev into main
Reviewed-on: #53
2026-09-11 14:36:13 +00:00
NiekRabelink 6ebaf027bf feat(planner): add functionality to find and plan tasks in the next available time slot, including date selection and UI updates 2026-09-11 16:35:43 +02:00
NiekRabelink d870e643dc Merge pull request 'feat(jira): enhance Jira planning modal with customizable title, date input, and collapsible description' (#52) from dev into main
Reviewed-on: #52
2026-09-11 14:24:23 +00:00
NiekRabelink d15303ff6c feat(jira): enhance Jira planning modal with customizable title, date input, and collapsible description 2026-09-11 16:22:13 +02:00
NiekRabelink bc6fdd249e Merge pull request 'feat(avatar): implement avatar upload processing and validation, update user interface for avatar display' (#51) from dev into main
Reviewed-on: #51
2026-09-11 14:01:09 +00:00
NiekRabelink 35ce20cd59 feat(avatar): implement avatar upload processing and validation, update user interface for avatar display 2026-09-11 16:00:48 +02:00
NiekRabelink e3a4d862bd Merge pull request 'Dev' (#50) from dev into main
Reviewed-on: #50
2026-09-11 13:52:21 +00:00
NiekRabelink e012d6d21c feat(security): implement session management features including idle timeout, keepalive, and secure logout 2026-09-11 15:47:09 +02:00
NiekRabelink 2dc0afc7a0 Merge pull request 'feat(security): implement SOC 2 & ISO 27001 audit logging, password policy, and session timeout' (#49) from dev into main
Reviewed-on: #49
2026-09-11 13:43:22 +00:00
NiekRabelink dc740fb94d feat(widgets): allow users to customize widget titles from settings gear dropdown 2026-09-11 15:39:36 +02:00
NiekRabelink 0942a94edd feat(security): implement SOC 2 & ISO 27001 audit logging, password policy, and session timeout 2026-09-11 15:35:47 +02:00
NiekRabelink e841d29b63 Merge branch 'dev' into main: fix widget layout persistence 2026-09-11 15:25:30 +02:00
NiekRabelink 88ec1418cf fix(planner): fix widget layout drag-and-drop order persistence across page navigation 2026-09-11 15:25:19 +02:00
NiekRabelink 0436c076e3 fix(config): restore dev config/.env and add DB connection safety net 2026-09-11 15:17:53 +02:00
NiekRabelink 6a9935e5cf fix(config): restore live config/.env and add DB connection safety net 2026-09-11 15:17:17 +02:00
NiekRabelink cc5d4a027f Merge pull request 'Dev' (#48) from dev into main
Reviewed-on: #48
2026-09-11 13:12:36 +00:00
NiekRabelink 0092cc17ef refactor(config): remove config/.env from git tracking, restore config/.env.example, and update .gitignore 2026-09-11 15:11:03 +02:00
NiekRabelink b3dfbf4406 refactor(config): remove config/.env from git tracking, restore config/.env.example, and update .gitignore 2026-09-11 15:10:40 +02:00
NiekRabelink 939e0518df Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-09-11 15:06:45 +02:00
NiekRabelink 61043cd61c feat(gitignore): enhance environment variable section for clarity and completeness 2026-09-11 15:06:42 +02:00
NiekRabelink 4f7db2ec83 feat(config): enhance .env loading to check both config/.env and root .env 2026-09-11 15:06:27 +02:00
NiekRabelink 795e41561c Update config/.env 2026-09-11 13:04:04 +00:00
NiekRabelink 29b8ce9211 Update config/.env 2026-09-11 13:03:51 +00:00
NiekRabelink 411fa79d7e Update config/.env 2026-09-11 13:01:11 +00:00
NiekRabelink cc439c8d0c Update config/.env 2026-09-11 13:00:56 +00:00
NiekRabelink 6060f39566 Merge pull request 'Implement CSRF protection for user invitation and management actions; add QR code generation script; secure internal includes directory with .htaccess' (#46) from dev into main
Reviewed-on: #46
2026-09-11 12:57:44 +00:00
NiekRabelink cf0ceeca82 Implement CSRF protection for user invitation and management actions; add QR code generation script; secure internal includes directory with .htaccess 2026-09-11 14:46:08 +02:00
NiekRabelink 4026efc816 Merge pull request 'Dev' (#45) from dev into main
Reviewed-on: #45
2026-09-11 11:27:27 +00:00
NiekRabelink 55618ffaed feat(security): implement IP verification and encryption for sensitive tokens 2026-09-11 13:17:12 +02:00
NiekRabelink c3001cdd0a feat(login): update TOTP input field to accept 6 digits and improve accessibility
feat(planner): add Jira ticket detail modal with scheduling options and time suggestions
2026-09-11 12:42:26 +02:00
NiekRabelink 29adb48dd9 Merge pull request 'feat: Add story points support and AI daily briefing feature' (#44) from dev into main
Reviewed-on: #44
2026-09-10 20:38:37 +00:00
NiekRabelink 5ce08443cc feat: Add story points support and AI daily briefing feature
- Enhanced JIRA integration to fetch story points from multiple custom fields.
- Updated task display to show story points and their estimated duration.
- Introduced AI daily briefing banner with dynamic content loading.
- Implemented subtasks functionality with add, toggle, and delete capabilities.
- Improved task management UI with better accessibility and interaction.
2026-09-10 22:38:06 +02:00
NiekRabelink bf944e063b Merge pull request 'Dev' (#43) from dev into main
Reviewed-on: #43
2026-09-10 14:51:56 +00:00
NiekRabelink c0e0dd5947 feat(jira): add conditional rendering for sync error banner 2026-09-10 16:51:41 +02:00
NiekRabelink f1d5c55c03 feat(planner): enhance timeline widget with integrated week overview and improved navigation 2026-09-10 16:31:26 +02:00
NiekRabelink b608e21ac2 Merge pull request 'Dev' (#42) from dev into main
Reviewed-on: #42
2026-09-10 14:16:04 +00:00
NiekRabelink 191b5115b7 feat(widget): enhance drag-and-drop functionality with new hover ghost and blueprint grid 2026-09-10 16:15:49 +02:00
NiekRabelink fa4c8c0e17 Implement feature X to enhance user experience and fix bug Y in module Z 2026-09-10 16:04:25 +02:00
NiekRabelink 144721585c Merge pull request 'Dev' (#41) from dev into main
Reviewed-on: #41
2026-09-10 13:59:37 +00:00
NiekRabelink 54ecc548bc Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-09-10 15:59:03 +02:00
NiekRabelink a92b4d0f4c feat(widget): enhance widget settings menu with improved styling and functionality 2026-09-10 15:57:32 +02:00
NiekRabelink 2bc6c8c55d Merge pull request 'Enhance email rendering and Jira widget functionality' (#40) from dev into main
Reviewed-on: #40
2026-09-10 13:44:56 +00:00
NiekRabelink e161c72fc4 Enhance email rendering and Jira widget functionality
- Implemented a secure rendering method for email content using a sandboxed iframe to prevent XSS attacks.
- Added thorough HTML sanitization for incoming emails to mitigate potential exploits.
- Refactored email body rendering logic to utilize a dedicated function for better readability and maintainability.
- Updated Jira widget to fetch and display dashboard data, including assigned issues and team issues, with improved filtering options.
- Enhanced the user interface of the Jira widget with a standardized control toolbar for resizing and collapsing widgets.
- Introduced inline search functionality within the Jira widget for quick ticket filtering.
- Improved error handling and logging for Jira data fetching.
2026-09-10 15:44:37 +02:00
NiekRabelink 26076f92f8 Merge pull request 'feat(cache): implement centralized caching layer for improved performance and reduced API calls' (#39) from dev into main
Reviewed-on: #39
2026-09-10 13:20:56 +00:00
NiekRabelink 72ad1fb70e feat(cache): implement centralized caching layer for improved performance and reduced API calls
feat(microsoft-integration): enhance inbox and events fetching with caching support
feat(auth): include theme and notification settings in user data retrieval
feat(profile): improve password visibility toggle functionality
feat(planner): optimize event synchronization with Outlook using caching
fix(notifications): update mail badge handling for mobile and desktop
2026-09-10 15:19:46 +02:00
NiekRabelink 0c6fa507b6 Merge pull request 'feat(microsoft-integration): update Microsoft 365 settings and admin consent handling for improved clarity and functionality' (#38) from dev into main
Reviewed-on: #38
2026-09-10 12:47:46 +00:00
NiekRabelink 5310b87fe5 feat(microsoft-integration): update Microsoft 365 settings and admin consent handling for improved clarity and functionality 2026-09-10 14:47:34 +02:00
NiekRabelink 35b8fa24f5 Merge pull request 'feat(microsoft-integration): enhance email permissions and consent flow for Outlook integration' (#37) from dev into main
Reviewed-on: #37
2026-09-10 12:42:52 +00:00
NiekRabelink 291fb761c7 feat(microsoft-integration): enhance email permissions and consent flow for Outlook integration 2026-09-10 14:42:19 +02:00
NiekRabelink df6dadee6f Merge pull request 'Implement code changes to enhance functionality and improve performance' (#36) from dev into main
Reviewed-on: #36
2026-09-10 12:30:21 +00:00
NiekRabelink 8a930f572c Implement code changes to enhance functionality and improve performance 2026-09-10 14:30:05 +02:00
NiekRabelink 15caa5f341 Merge pull request 'feat(planner): add widget layout saving and scratchpad note functionality' (#35) from dev into main
Reviewed-on: #35
2026-09-10 12:17:54 +00:00
NiekRabelink fb38c42fe9 feat(planner): add widget layout saving and scratchpad note functionality
feat(notifications): implement real-time live sync for mail, Jira, and calendar updates

fix(database): add new columns for widget layout and last check timestamps

fix(schema): update users table schema to include new fields for widget layout and last checks

feat(microsoft): enhance Microsoft integration with demo email data and inbox fetching

feat(jira): add listener for live updates on Jira tickets
2026-09-10 14:16:45 +02:00
NiekRabelink c6e7a8d117 Merge pull request 'Dev' (#34) from dev into main
Reviewed-on: #34
2026-09-10 12:10:19 +00:00
NiekRabelink dc0b33a278 Add webhook receivers for Jira and Microsoft Graph, and implement Outlook 365 email hub with inbox management features 2026-09-10 14:08:34 +02:00
NiekRabelink 25c2ffeef8 Jira bord changes 2026-09-10 13:50:31 +02:00
NiekRabelink 03c7fb3067 feat(profile): update avatar selection with category filters and enhanced UI
- Replaced standard avatar titles with more engaging names.
- Added category filter buttons for avatars: Fun & Emojis, Cyber & Bots, Modern Personas, Adventurer & Heroes.
- Refactored avatar loading logic to use an array of avatar presets with associated categories and names.
- Enhanced button styles for better user interaction feedback.
- Implemented JavaScript function to filter displayed avatars based on selected category.
2026-09-10 13:27:36 +02:00
NiekRabelink 2270e5875d Merge pull request 'Enhance Jira integration by adding fallback mechanisms and improving field handling in issue fetching' (#33) from dev into main
Reviewed-on: #33
2026-09-10 11:15:04 +00:00
NiekRabelink 47b0d38084 Enhance Jira integration by adding fallback mechanisms and improving field handling in issue fetching 2026-09-10 13:14:51 +02:00
NiekRabelink 64fbd8079d Merge pull request 'Dev' (#32) from dev into main
Reviewed-on: #32
2026-09-10 10:50:23 +00:00
NiekRabelink ffc824f27c Improve error handling and feedback for password reset functionality 2026-09-10 12:49:37 +02:00
NiekRabelink 130a43eabc Refactor code structure for improved readability and maintainability 2026-09-10 11:27:31 +02:00
NiekRabelink 9c4d1e9618 Merge pull request 'Add password reset functionality and SMTP mailer class' (#31) from dev into main
Reviewed-on: #31
2026-09-10 08:01:41 +00:00
NiekRabelink a7aad57e0b Add password reset functionality and SMTP mailer class
- Implemented reset-password.php for users to reset their passwords securely.
- Added validation for reset tokens and password strength checks.
- Created mailer.php to handle sending emails via SMTP, including password reset and invitation emails.
- Integrated responsive HTML email templates for better user experience.
2026-09-10 09:57:53 +02:00
NiekRabelink c65d4a2525 Merge pull request 'Refactor code structure for improved readability and maintainability' (#30) from dev into main
Reviewed-on: #30
2026-09-09 20:31:41 +00:00
NiekRabelink d4fd706eca Refactor code structure for improved readability and maintainability 2026-09-09 22:31:19 +02:00
NiekRabelink 08307f5fe7 Merge pull request 'Refactor board layout for improved full-width maximization and responsive design' (#29) from dev into main
Reviewed-on: #29
2026-09-09 20:07:05 +00:00
NiekRabelink eda90dbba6 Refactor board layout for improved full-width maximization and responsive design 2026-09-09 22:06:42 +02:00
NiekRabelink 4163199d16 Merge pull request 'Allow board to always use full screen width and hide header on maximize' (#28) from dev into main
Reviewed-on: #28
2026-09-09 19:44:16 +00:00
NiekRabelink 1a6b82b583 Allow board to always use full screen width and hide header on maximize 2026-09-09 21:42:34 +02:00
NiekRabelink 302b6fb555 Merge pull request 'Update board layout: add small maximize icon directly above lanes and enable true full-width screen expansion' (#27) from dev into main
Reviewed-on: #27
2026-09-09 19:35:27 +00:00
NiekRabelink ff11f428a6 Update board layout: add small maximize icon directly above lanes and enable true full-width screen expansion 2026-09-09 21:34:38 +02:00
NiekRabelink 4fbe30b4b6 Merge pull request 'Enhance board and planner UI; implement smooth width transitions, update icons, and improve layout responsiveness' (#26) from dev into main
Reviewed-on: #26
2026-09-09 19:21:48 +00:00
NiekRabelink 1aad7f5d7f Enhance board and planner UI; implement smooth width transitions, update icons, and improve layout responsiveness 2026-09-09 21:19:57 +02:00
NiekRabelink c6b322cb49 Merge pull request 'Dev' (#25) from dev into main
Reviewed-on: #25
2026-09-09 19:03:34 +00:00
NiekRabelink d83704d0df Add .continue and .continueignore to .gitignore for better environment management 2026-09-09 21:02:46 +02:00
NiekRabelink eaee4d5f1c Refactor code structure for improved readability and maintainability 2026-09-09 20:54:42 +02:00
NiekRabelink bcf2323124 Merge pull request 'Enhance session management and user authentication flow; implement day session tokens, improve cookie handling, and streamline date handling in planner' (#24) from dev into main
Reviewed-on: #24
2026-09-09 17:19:18 +00:00
NiekRabelink 963342f858 Enhance session management and user authentication flow; implement day session tokens, improve cookie handling, and streamline date handling in planner 2026-09-09 19:18:42 +02:00
NiekRabelink 2062fd065a Implement seamless AJAX date switching and in-place updates in planner to preserve scroll position and eliminate full-page reload flickers 2026-09-09 19:02:40 +02:00
NiekRabelink 0b9e92eded Fix undefined verify_totp function in login.php and add TOTP helper functions; enhance debug diagnostics 2026-09-09 18:03:56 +02:00
NiekRabelink dfaa8ba7a8 Make config.php environment-aware with smart host detection, normalize .gitattributes, and use dynamic APP_URL in Outlook integrations 2026-09-09 17:58:19 +02:00
NiekRabelink ed5bd6f998 Reformat .gitignore for consistency and readability 2026-09-09 17:54:57 +02:00
90 changed files with 31794 additions and 1741 deletions
+14 -1
View File
@@ -1,3 +1,16 @@
# Zorg dat omgevingsspecifieke bestanden nooit worden overschreven bij merges tussen dev en main
# 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
+10 -2
View File
@@ -1,6 +1,8 @@
# Environment
.env
.env*.local
/.env
/.env.local
*.local
!config/.env
# Systeem & Editor
.DS_Store
@@ -8,3 +10,9 @@ Thumbs.db
*.log
.vscode/
.idea/
.continue/
.continueignore
# Uploads
/assets/uploads/*
!/assets/uploads/**/.gitkeep
+2287 -256
View File
File diff suppressed because it is too large Load Diff
+485 -18
View File
@@ -43,7 +43,7 @@ try {
exit;
}
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards);
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards, $user['id']);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
@@ -72,6 +72,7 @@ try {
$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']);
@@ -87,22 +88,76 @@ try {
exit;
}
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count);
$authorName = 'AI Co-Pilot';
$createdCards = [];
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
$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)
");
$createdCards = [];
$authorName = 'AI Co-Pilot';
foreach ($aiRes['ideas'] as $ideaText) {
foreach ($ideas as $ideaText) {
$ideaText = trim($ideaText);
if (!empty($ideaText)) {
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
@@ -117,7 +172,7 @@ try {
}
}
echo json_encode(['success' => true, 'cards' => $createdCards]);
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'ideas']);
exit;
}
@@ -132,7 +187,7 @@ try {
exit;
}
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle);
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle, $user['id']);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
@@ -143,7 +198,298 @@ try {
exit;
}
// 4. Slimme dagindeling berekenen voor taken rondom Outlook afspraken
// 3b. AI Brainstorm Idee Uitwerken tot concrete behapbare items
if ($action === 'breakdown_idea') {
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$content = trim($data['content'] ?? '');
$sessionId = (int)($data['session_id'] ?? 0);
$sessionTitle = trim($data['session_title'] ?? '');
$theme = trim($data['theme'] ?? '');
if (empty($content)) {
echo json_encode(['error' => 'Geen inhoud opgegeven om uit te werken.']);
exit;
}
if ($sessionId > 0 && (empty($sessionTitle) || empty($theme))) {
$stmtS = $db->prepare("SELECT title, theme FROM brainstorm_sessions WHERE id = ?");
$stmtS->execute([$sessionId]);
$sRow = $stmtS->fetch();
if ($sRow) {
if (empty($sessionTitle)) $sessionTitle = $sRow['title'] ?? '';
if (empty($theme)) $theme = $sRow['theme'] ?? '';
}
}
$aiRes = fociz_ai_breakdown_idea($content, $sessionTitle, $theme, $user['id']);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
echo json_encode([
'success' => true,
'summary' => $aiRes['summary'],
'items' => $aiRes['items'],
'model' => $aiRes['model'] ?? 'Gemini Pro',
]);
exit;
}
// 3c. Opgesplitste AI items exporteren naar Kanban Bord, Tijdlijn of Dagplanner
if ($action === 'save_breakdown_items') {
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$destination = trim($data['destination'] ?? 'kanban'); // 'kanban', 'timeline', 'planner'
$items = $data['items'] ?? [];
if (empty($items) || !is_array($items)) {
echo json_encode(['error' => 'Geen items geselecteerd om op te slaan.']);
exit;
}
$isAdmin = ($user['role'] === 'ADMIN');
$createdCount = 0;
if ($destination === 'kanban' || $destination === 'timeline') {
$boardId = (int)($data['board_id'] ?? 0);
$laneId = (int)($data['lane_id'] ?? 0);
if ($boardId <= 0) {
// Pak het eerste actieve bord van de gebruiker
if ($isAdmin) {
$boardId = (int)$db->query("SELECT id FROM boards WHERE is_archived = 0 ORDER BY id ASC LIMIT 1")->fetchColumn();
} else {
$stmtB = $db->prepare("SELECT b.id FROM boards b JOIN board_members bm ON b.id = bm.board_id WHERE bm.user_id = ? AND b.is_archived = 0 ORDER BY b.id ASC LIMIT 1");
$stmtB->execute([$user['id']]);
$boardId = (int)$stmtB->fetchColumn();
}
}
if ($boardId <= 0) {
echo json_encode(['error' => 'Geen actief Kanban-bord gevonden om taken aan toe te voegen.']);
exit;
}
// Toegangscontrole
if (!$isAdmin) {
$stmtCheck = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
$stmtCheck->execute([$boardId, $user['id']]);
if ((int)$stmtCheck->fetchColumn() === 0) {
http_response_code(403);
echo json_encode(['error' => 'Geen toegang tot dit bord.']);
exit;
}
}
// Als lane_id niet geldig is, pak de eerste kolom (bijv. 'To Do' of 'Backlog')
if ($laneId <= 0) {
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
$stmtLane->execute([$boardId]);
$laneId = (int)$stmtLane->fetchColumn();
}
if ($laneId <= 0) {
echo json_encode(['error' => 'Het geselecteerde bord heeft geen kolommen.']);
exit;
}
// Datumbereik voor Tijdlijn
$startDate = !empty($data['start_date']) ? trim($data['start_date']) : date('Y-m-d');
$endDate = !empty($data['end_date']) ? trim($data['end_date']) : date('Y-m-d', strtotime('+3 days'));
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
$stmtInsertCard = $db->prepare("
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmtSchedule = $db->prepare("
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
VALUES (?, 'DAILY_RANGE', ?, ?, '09:00', '17:00', '1,2,3,4,5')
");
$priorityMap = ['P1' => 'HIGH', 'P2' => 'MEDIUM', 'P3' => 'LOW'];
foreach ($items as $item) {
$title = trim($item['title'] ?? '');
if (empty($title)) continue;
$desc = trim($item['description'] ?? '');
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
$priority = $priorityMap[$rawPri] ?? ($rawPri === 'HIGH' || $rawPri === 'LOW' ? $rawPri : 'MEDIUM');
$color = trim($item['color'] ?? 'indigo');
$stmtMax->execute([$laneId]);
$maxPos = (int)$stmtMax->fetchColumn();
$stmtInsertCard->execute([
$boardId,
$laneId,
$title,
$desc,
$user['id'],
$priority,
$color,
$maxPos + 1,
$user['id'],
]);
$cardId = (int)$db->lastInsertId();
if ($destination === 'timeline' && $cardId > 0) {
$stmtSchedule->execute([$cardId, $startDate, $endDate]);
}
$createdCount++;
}
echo json_encode([
'success' => true,
'destination' => $destination,
'created_count' => $createdCount,
'board_id' => $boardId,
]);
exit;
}
if ($destination === 'planner') {
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : date('Y-m-d');
// Zoek of maak daily plan
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
$stmtPlan->execute([$user['id'], $planDate]);
$planId = (int)$stmtPlan->fetchColumn();
if ($planId <= 0) {
$stmtNewPlan = $db->prepare("INSERT INTO daily_plans (user_id, plan_date, focus_goal) VALUES (?, ?, 'Brainstorm actiepunten oppakken')");
$stmtNewPlan->execute([$user['id'], $planDate]);
$planId = (int)$db->lastInsertId();
}
$stmtInsertTask = $db->prepare("
INSERT INTO tasks (user_id, daily_plan_id, title, priority, duration_minutes, status, source)
VALUES (?, ?, ?, ?, ?, 'TODO', 'BRAINSTORM')
");
foreach ($items as $item) {
$title = trim($item['title'] ?? '');
if (empty($title)) continue;
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
$priority = in_array($rawPri, ['P1', 'P2', 'P3']) ? $rawPri : 'P2';
$duration = max(15, min(240, (int)($item['estimated_minutes'] ?? 30)));
$stmtInsertTask->execute([
$user['id'],
$planId,
$title,
$priority,
$duration,
]);
$createdCount++;
}
echo json_encode([
'success' => true,
'destination' => 'planner',
'created_count' => $createdCount,
'plan_date' => $planDate,
]);
exit;
}
echo json_encode(['error' => 'Onbekende bestemming: ' . htmlspecialchars($destination)]);
exit;
}
// 3d. Test AI Verbinding (Gemini Pro / OpenAI)
if ($action === 'test_connection') {
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');
@@ -154,27 +500,46 @@ try {
$plan = $stmtPlan->fetch();
$planId = $plan['id'] ?? 0;
// Haal openstaande taken op voor de dag (inclusief fallback voor ongebonden taken op de huidige dag)
// ZERO-DATA LEAKAGE: Sluit expliciet alle taken uit die afkomstig zijn van Jira, Mail of Teams!
$isToday = ($date === date('Y-m-d'));
$stmtTasks = $db->prepare("
SELECT t.id, t.title, t.priority, t.duration_minutes
FROM tasks t
WHERE t.user_id = ? AND (t.daily_plan_id = ? " . ($isToday ? "OR t.daily_plan_id IS NULL" : "") . ") AND t.is_completed = 0
WHERE t.user_id = ?
AND (t.daily_plan_id = ? " . ($isToday ? "OR t.daily_plan_id IS NULL" : "") . ")
AND t.is_completed = 0
AND (t.source IS NULL OR t.source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
AND t.external_id IS NULL
ORDER BY t.priority ASC, t.created_at ASC
");
$stmtTasks->execute([$user['id'], $planId]);
$tasks = $stmtTasks->fetchAll();
if (empty($tasks)) {
echo json_encode(['error' => 'Geen openstaande taken gevonden om in te delen']);
echo json_encode(['error' => 'Geen openstaande Fociz taken gevonden om in te delen']);
exit;
}
// ZERO-DATA LEAKAGE: Haal meetings op maar geef UITSLUITEND start/eindtijd door (geen titel/deelnemers)
$busyBlocks = [];
try {
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$events = microsoft_fetch_today_events($user['id'], $date);
$rawEvents = microsoft_fetch_today_events($user['id'], $date);
foreach ($rawEvents as $ev) {
if (!empty($ev['start']) && !empty($ev['end'])) {
$busyBlocks[] = [
'start' => substr($ev['start'], 0, 5),
'end' => substr($ev['end'], 0, 5),
];
}
}
} catch (Throwable $e) {
// Microsoft niet gekoppeld
}
$workdaySettings = get_workday_settings();
$aiRes = fociz_ai_smart_schedule($tasks, $events, $workdaySettings);
$aiRes = fociz_ai_smart_schedule($tasks, $busyBlocks, $workdaySettings, $user['id']);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
@@ -205,8 +570,110 @@ try {
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 (Exception $e) {
} catch (Throwable $e) {
error_log("api/ai.php Throwable: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
echo json_encode([
'success' => false,
'error' => 'Serverfout bij AI verwerking: ' . $e->getMessage()
]);
}
+611
View File
@@ -0,0 +1,611 @@
<?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()]);
}
+281 -3
View File
@@ -47,11 +47,25 @@ try {
exit;
}
// 2. Stemmen / Upvote op kaart
// 2. Stemmen / Upvote op kaart (met bescherming tegen oneindig stemmen)
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]);
@@ -63,11 +77,35 @@ try {
exit;
}
// 3. Kaart verwijderen
// 3. Kaart verwijderen (met autorisatiecontrole: auteur, sessie-eigenaar of beheerder)
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]);
@@ -75,7 +113,200 @@ try {
exit;
}
// 4. Omzetten naar Roadmap!
// 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;
}
if ($action === 'convert_to_roadmap') {
$data = json_decode(file_get_contents('php://input'), true);
$cardIds = $data['card_ids'] ?? [];
@@ -137,6 +368,53 @@ 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();
+312 -2
View File
@@ -28,7 +28,7 @@ try {
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
$stmt->execute([$user['id']]);
$saved = json_decode($stmt->fetchColumn() ?: '{}', true);
$apiToken = $saved['jira_api_token'] ?? '';
$apiToken = fociz_decrypt($saved['jira_api_token'] ?? '');
if (empty($domain)) $domain = $saved['jira_domain'] ?? '';
if (empty($email)) $email = $saved['jira_email'] ?? '';
}
@@ -40,7 +40,7 @@ try {
$settings = json_encode([
'jira_domain' => jira_normalize_domain($domain),
'jira_email' => $email,
'jira_api_token' => $apiToken,
'jira_api_token' => fociz_encrypt($apiToken),
'account_name' => $jiraUser['displayName'] ?? '',
'account_id' => $jiraUser['accountId'] ?? '',
]);
@@ -58,6 +58,7 @@ try {
// Wis eventuele oude sessie cache
unset($_SESSION["jira_issues_user_{$user['id']}"]);
unset($_SESSION["jira_dashboard_user_{$user['id']}"]);
}
echo json_encode($res);
@@ -72,6 +73,214 @@ 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');
@@ -106,6 +315,107 @@ 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);
+163
View File
@@ -0,0 +1,163 @@
<?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(),
]);
}
+288
View File
@@ -0,0 +1,288 @@
<?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()]);
}
+5
View File
@@ -9,6 +9,11 @@ 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'] ?? '');
+1192 -17
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -53,6 +53,30 @@ 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));
+124
View File
@@ -0,0 +1,124 @@
<?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;
+56
View File
@@ -0,0 +1,56 @@
<?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()]);
}
+31
View File
@@ -0,0 +1,31 @@
<?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
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 4.6 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 4.4 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

+20
View File
@@ -0,0 +1,20 @@
<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>

After

Width:  |  Height:  |  Size: 1.2 KiB

+21
View File
@@ -0,0 +1,21 @@
<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>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+123 -4
View File
@@ -253,9 +253,12 @@
if (isFetchingEvents) return;
isFetchingEvents = true;
try {
const res = await fetch('/api/notifications.php?action=get_events', { cache: 'no-store' });
const res = await fetch('/api/notifications.php?action=get_events', {
cache: 'no-store',
headers: { 'X-Background-Poll': '1' }
});
if (res.status === 401) {
window.location.href = '/auth/login.php?expired=1';
console.warn('Notificatiesessie niet geauthenticeerd.');
return;
}
if (res.ok) {
@@ -408,13 +411,129 @@
}
};
// 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();
// Polling interval elke 60 seconden
checkLiveSync();
// Polling interval elke 60 seconden voor agenda evaluatie
setInterval(refreshEvents, 60000);
// Evaluatie interval elke 15 seconden
// 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
View File
File diff suppressed because one or more lines are too long
+291
View File
@@ -0,0 +1,291 @@
/**
* 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);
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+22 -9
View File
@@ -52,6 +52,13 @@ if (!empty($_GET['error'])) {
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)
$expectedState = $_SESSION['ms_oauth_state'] ?? '';
unset($_SESSION['ms_oauth_state'], $_SESSION['ms_oauth_user_id']);
@@ -61,12 +68,6 @@ if (!$stateUserId && (empty($receivedState) || empty($expectedState) || !hash_eq
exit;
}
// Afhandeling van beheerderstoestemming (Admin Consent)
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 koppelen via Mijn Profiel.'));
exit;
}
// 5. Autorisatiecode controleren
$code = $_GET['code'] ?? '';
if (empty($code)) {
@@ -136,9 +137,21 @@ $saveStmt = $db->prepare("
settings = VALUES(settings),
last_sync_at = NOW()
");
$saveStmt->execute([$user['id'], $accessToken, $refreshToken, json_encode($newSettings)]);
$saveStmt->execute([
$user['id'],
fociz_encrypt($accessToken),
fociz_encrypt($refreshToken),
json_encode($newSettings)
]);
// 8. Succesvol terugkeren naar de Persoonlijke Profiel Hub
header('Location: /profile.php?success=' . urlencode("Microsoft Outlook succesvol gekoppeld als {$accountEmail}!"));
// 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);
exit;
+232
View File
@@ -0,0 +1,232 @@
<?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 &bull; Fociz 2026
</div>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
</body>
</html>
+35 -7
View File
@@ -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 (strlen($password) < 8) {
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn.";
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
$error = $pwErr;
} elseif ($password !== $confirmPassword) {
$error = "Wachtwoorden komen niet overeen.";
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
@@ -75,15 +75,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db->commit();
// Start dagsessie (geldig tot 23:59:59)
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();
$_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');
@@ -105,6 +119,8 @@ $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() {
@@ -170,12 +186,12 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
<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. 8 tekens)</label>
<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">
<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">
</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 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="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">
</div>
</div>
@@ -189,7 +205,7 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
</p>
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-40 h-40 mx-auto">
<div id="qrcode" class="w-40 h-40 mx-auto flex items-center justify-center"></div>
</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">
@@ -211,8 +227,20 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
</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>
+179 -15
View File
@@ -12,6 +12,18 @@ 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) {
@@ -22,27 +34,76 @@ if ($currentUser) {
$error = null;
$infoMessage = null;
if (isset($_GET['expired'])) {
$infoMessage = "Uw dagsessie is om 23:59 beëindigd. Log opnieuw in om een nieuwe werkdag te starten.";
$infoMessage = "Uw 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']);
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']
);
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 (empty($email) || empty($password)) {
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)) {
$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 = ?");
@@ -50,12 +111,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
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.
$_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.";
}
}
@@ -70,7 +156,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
if (empty($totpCode)) {
$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.";
} else {
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
@@ -81,7 +172,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Controleer reguliere TOTP code
if (!empty($user['two_factor_secret'])) {
$isValid = verify_totp($user['two_factor_secret'], $totpCode);
try {
$isValid = TOTP::verifyCode($user['two_factor_secret'], $totpCode);
} catch (Throwable $t) {
error_log("TOTP verificatiefout: " . $t->getMessage());
$isValid = false;
}
}
// Fallback: controleer 8-karakter backup codes
@@ -99,23 +195,60 @@ 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();
$_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();
// Zorg dat er geen persistente remember cookies worden gebruikt
clear_remember_cookie();
// Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag)
issue_day_session_token((int)$user['id']);
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
// 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']
);
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.";
}
}
@@ -130,6 +263,8 @@ $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() {
@@ -201,6 +336,7 @@ $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>
<div class="relative">
@@ -209,7 +345,10 @@ $pageTitle = "Fociz — Inloggen";
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord</label>
<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>
<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">
</div>
@@ -225,6 +364,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">
<i data-lucide="key" class="w-6 h-6"></i>
@@ -238,10 +401,7 @@ $pageTitle = "Fociz — Inloggen";
</div>
<div>
<input type="text" name="totp_code" required autofocus maxlength="9" placeholder="000 000" class="w-full text-center tracking-[0.4em] font-mono text-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
<span class="text-[11px] text-slate-500 dark:text-slate-500 text-center block mt-1">
Tip: U kunt ook een 8-cijferige herstelcode invoeren.
</span>
<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">
</div>
<div class="flex space-x-3">
@@ -263,6 +423,10 @@ $pageTitle = "Fociz — Inloggen";
</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>
+6 -2
View File
@@ -6,13 +6,17 @@
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
// Vernietig persistente login in database
// Vernietig persistente login en sessie in database (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
if (!empty($_SESSION['user_id'])) {
$uid = (int)$_SESSION['user_id'];
try {
$db = get_db_connection();
ensure_remember_token_columns($db);
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$stmt->execute([$uid]);
fociz_terminate_session(session_id());
fociz_log_audit($uid, 'SESSION_TERMINATED_LOGOUT', 'users', $uid, ['ip' => fociz_get_client_ip()], 'SUCCESS', $_SESSION['user_email'] ?? null);
} catch (Throwable $e) {
// Stil negeren bij uitloggen
}
+10 -1
View File
@@ -43,8 +43,17 @@ $state = generate_oauth_state((int)$user['id']);
$_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']);
// Stuur gebruiker door naar Microsoft login
$authUrl = microsoft_get_auth_url($clientId, $redirectUri, $tenantId, $state);
$authUrl = microsoft_get_auth_url($clientId, $redirectUri, $tenantId, $state, $forceConsent);
header("Location: {$authUrl}");
exit;
+286
View File
@@ -0,0 +1,286 @@
<?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 &bull; 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 -7
View File
@@ -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 (strlen($password) < 8) {
$error = "Het wachtwoord moet minimaal 8 tekens bevatten.";
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
$error = $pwErr;
} elseif ($password !== $confirmPassword) {
$error = "De ingevoerde wachtwoorden komen niet overeen.";
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
@@ -62,17 +62,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$adminId = (int)$db->lastInsertId();
// Automatische dagsessie starten (geldig tot 23:59:59)
// Automatische dagsessie starten met verse sessie ID (OWASP ASVS v4.0 §3.3.1)
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
$newSessionId = session_id();
$_SESSION['user_id'] = $adminId;
$_SESSION['user_name'] = $name;
$_SESSION['user_email'] = $email;
$_SESSION['user_role'] = 'ADMIN';
$_SESSION['is_2fa_verified'] = true;
$_SESSION['auth_date'] = date('Y-m-d');
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
$_SESSION['last_activity'] = time();
$_SESSION['login_time'] = time();
$_SESSION['last_rotated'] = time();
clear_remember_cookie();
issue_day_session_token($adminId);
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
fociz_register_active_session($adminId, $newSessionId, $maxIdleSeconds);
unset($_SESSION['setup_totp_secret']);
fociz_log_audit($adminId, 'INITIAL_ADMIN_SETUP', 'user', $adminId, ['email' => $email, 'name' => $name], 'SUCCESS', $email);
$success = true;
} catch (Exception $e) {
$error = "Fout bij aanmaken beheerder in MariaDB: " . $e->getMessage();
@@ -90,6 +104,8 @@ $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() {
@@ -191,12 +207,12 @@ $pageTitle = "Fociz — Eerste Initialisatie";
<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. 8 tekens)</label>
<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">
<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">
</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 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="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">
</div>
</div>
</div>
@@ -212,7 +228,7 @@ $pageTitle = "Fociz — Eerste Initialisatie";
</p>
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-44 h-44 mx-auto">
<div id="qrcode" class="w-44 h-44 mx-auto flex items-center justify-center"></div>
</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">
@@ -236,8 +252,20 @@ $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>
+136
View File
@@ -0,0 +1,136 @@
<?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>
+1565
View File
File diff suppressed because it is too large Load Diff
+207 -27
View File
@@ -4,15 +4,22 @@
*/
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/settings.php';
$currentUser = require_auth();
$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['theme'] ?? 'Product & Strategie');
$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';
}
$description = trim($_POST['description'] ?? '');
$autoAi = !empty($_POST['auto_ai']) ? 1 : 0;
if (!empty($title)) {
$stmt = $db->prepare("
@@ -21,7 +28,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
");
$stmt->execute([$title, $theme, $description, $currentUser['id']]);
$newId = $db->lastInsertId();
header("Location: /brainstorm_canvas.php?id={$newId}");
$redirectUrl = "/brainstorm_canvas.php?id={$newId}" . ($autoAi ? "&auto_ai=1" : "");
header("Location: {$redirectUrl}");
exit;
} else {
$error = "Titel van de brainstormsessie is verplicht.";
@@ -88,9 +96,11 @@ require_once __DIR__ . '/includes/header.php';
</button>
</div>
<?php else: ?>
<?php foreach ($sessions as $sess): ?>
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="group fociz-card-interactive p-5 flex flex-col justify-between hover:border-indigo-400 dark:hover:border-indigo-500/60">
<div>
<?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">
<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]">
<?= htmlspecialchars($sess['theme']) ?>
@@ -110,29 +120,46 @@ require_once __DIR__ . '/includes/header.php';
<?= htmlspecialchars($sess['description']) ?>
</p>
<?php endif; ?>
</div>
</a>
<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-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-bold flex-shrink-0 ml-2">
<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">
<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" 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 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">
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="sparkles" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Nieuwe Brainstormsessie</span>
</h3>
<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>
</button>
@@ -141,31 +168,78 @@ require_once __DIR__ . '/includes/header.php';
<form method="POST" action="/brainstorm.php" class="space-y-4">
<input type="hidden" name="action" value="create_session">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Onderwerp / Titel</label>
<input type="text" name="title" required placeholder="Bijv. Q4 Automatisering & Klantreis" 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.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">
<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 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">Thema / Categorie</label>
<select name="theme" 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">
<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): ?>
<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>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Doel / Beschrijving</label>
<input type="text" name="description" placeholder="Doel van deze sessie..." 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>
<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>
</div>
<textarea
name="description"
rows="6"
placeholder="Voer hier alle achtergrondinformatie, context en doelen in waar de AI post-its van gaat maken. Bijvoorbeeld:&#10;&#10;• Achtergrond: Klanten klagen over lange wachttijden bij onboarding.&#10;• Doel: Binnen 3 maanden de doorlooptijd met 50% verkorten.&#10;• Bekende knelpunten: Handmatige validatie, ontbrekende API-koppeling met CRM.&#10;• 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">
Sessie Aanmaken & Canvas Openen
<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>
</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">
Annuleren
@@ -175,4 +249,110 @@ 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'; ?>
+1511 -71
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"require": {
"php": "^8.2",
"php": "^8.4",
"ext-pdo": "*",
"ext-pdo_mysql": "*",
"ext-mysqli": "*"
+30
View File
@@ -0,0 +1,30 @@
# =========================================================================
# 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"
-28
View File
@@ -1,28 +0,0 @@
# =========================================================================
# FOCIZ OMGEVINGSCONFIGURATIE VOORBEELD (TEMPLATE)
# =========================================================================
# Alle actieve database- en applicatie-instellingen staan centraal in:
# config/config.php
#
# Indien gewenst kan een lokaal .env bestand in de hoofdmap worden geplaatst
# om specifieke variabelen lokaal te overschrijven.
DB_HOST="127.0.0.1"
DB_PORT="3306"
DB_NAME="fociz"
DB_USER="fociz_user"
DB_PASS=""
APP_ENV="live"
APP_URL="https://www.fociz.nl"
APP_SECRET=""
MICROSOFT_CLIENT_ID=""
MICROSOFT_CLIENT_SECRET=""
MICROSOFT_TENANT_ID=""
JIRA_DOMAIN=""
JIRA_EMAIL=""
JIRA_API_TOKEN=""
+74 -20
View File
@@ -17,14 +17,19 @@ if (!headers_sent()) {
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. Optionele .env inlezing uit de hoofdmap (uitsluitend .env, GEEN .env.example fallback)
// 1. .env inlezing (controleert eerst server root .env en dan config/.env)
$envVars = [];
$envFile = __DIR__ . '/../.env';
if (file_exists($envFile)) {
$envLocations = [__DIR__ . '/../.env', __DIR__ . '/.env'];
foreach ($envLocations as $envFile) {
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
@@ -43,6 +48,8 @@ if (file_exists($envFile)) {
}
}
}
break; // Eerste gevonden .env bestand is leidend
}
}
/**
@@ -69,22 +76,59 @@ if (!function_exists('fociz_env')) {
}
// =========================================================================
// 2. CENTRALE DATABASE CONFIGURATIE (DE ENIGE BRON VAN WAARHEID)
// =========================================================================
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
define('DB_PORT', fociz_env('DB_PORT', '3306'));
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');
// =========================================================================
// 3. APPLICATIE INSTELLINGEN & OMGEVING
// 2. APPLICATIE INSTELLINGEN & OMGEVINGSDETECTIE
// =========================================================================
define('APP_NAME', 'Fociz');
define('APP_ENV', fociz_env('APP_ENV', 'live')); // 'live' of 'development'
// 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');
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_CHARSET', 'utf8mb4');
// Bepaal de dynamische of geconfigureerde applicatie URL
if (!function_exists('fociz_detect_app_url')) {
function fociz_detect_app_url(): string {
@@ -113,6 +157,18 @@ define('APP_URL', fociz_detect_app_url());
// 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
// =========================================================================
@@ -126,12 +182,10 @@ if (APP_ENV === 'live') {
$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']}");
if (!headers_sent()) {
http_response_code(500);
}
if (isset($_SESSION['user_role']) && $_SESSION['user_role'] === 'ADMIN') {
$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 (Alleen zichtbaar voor Beheerder)</h3>
<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>";
+155 -1
View File
@@ -32,6 +32,7 @@ function get_db_connection(): PDO {
");
}
$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,
@@ -41,6 +42,9 @@ 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',
@@ -53,6 +57,8 @@ function get_db_connection(): PDO {
// 2. CHECK: Bestaat de database al?
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}'");
ensure_tables_exist($pdo);
return $pdo;
@@ -70,6 +76,7 @@ function get_db_connection(): PDO {
));
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
$pdo->exec("SET time_zone = '{$tzOffset}'");
ensure_tables_exist($pdo);
return $pdo;
@@ -119,8 +126,14 @@ function ensure_tables_exist(PDO $pdo): void {
if ($tablesChecked) return;
$tablesChecked = true;
try {
$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)) {
return;
}
try {
if (file_exists($schemaFile)) {
$sql = file_get_contents($schemaFile);
$statements = array_filter(array_map('trim', explode(';', $sql)));
@@ -146,7 +159,39 @@ function ensure_tables_exist(PDO $pdo): void {
"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 {
@@ -155,6 +200,115 @@ function ensure_tables_exist(PDO $pdo): void {
// 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());
}
+203 -1
View File
@@ -13,7 +13,13 @@ CREATE TABLE IF NOT EXISTS `users` (
`remember_token` VARCHAR(64) NULL,
`remember_token_expires_at` DATETIME NULL,
`notification_settings` TEXT NULL,
`avatar_url` VARCHAR(255) 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,
`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;
@@ -31,6 +37,17 @@ 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,
@@ -84,12 +101,18 @@ 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;
@@ -133,3 +156,182 @@ CREATE TABLE IF NOT EXISTS `system_settings` (
`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;
+20
View File
@@ -0,0 +1,20 @@
<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>

After

Width:  |  Height:  |  Size: 1.2 KiB

+20
View File
@@ -0,0 +1,20 @@
<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>

After

Width:  |  Height:  |  Size: 1.2 KiB

+9
View File
@@ -0,0 +1,9 @@
# 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>
+816 -30
View File
@@ -8,27 +8,420 @@ require_once __DIR__ . '/http.php';
require_once __DIR__ . '/settings.php';
/**
* Voert een gestructureerde chat completion uit met de geconfigureerde AI provider
* STRICT ZERO-DATA LEAKAGE PRIVACY FIREWALL
* Garandeert dat NOOIT data uit Jira, Mail (Outlook) of Microsoft Teams
* doorgestuurd kan worden naar een externe AI provider (Gemini / OpenAI).
*
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int]
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string]
* @param array $messages Referentie naar de messages array die verzonden wordt
* @return array Aantal en type geactiveerde firewall filters
*/
function fociz_ai_chat(array $messages, array $options = []): array {
$aiSettings = get_ai_settings();
if (empty($aiSettings['ai_api_key'])) {
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 AI API-sleutel geconfigureerd. Stel deze in via Systeembeheer (admin.php).',
'error' => 'Geen Google Gemini API-sleutel ingevuld of geconfigureerd.',
];
}
$apiKey = $aiSettings['ai_api_key'];
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gpt-4o-mini';
// 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,
@@ -47,12 +440,21 @@ function fociz_ai_chat(array $messages, array $options = []): array {
'Content-Type' => 'application/json',
],
'body' => $payload,
'timeout' => 35,
'timeout' => 20,
]);
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "AI aanroep mislukt (HTTP {$response['status']})");
error_log("fociz_ai_chat fout: {$errMsg}");
$errMsg = 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,
@@ -66,11 +468,230 @@ function fociz_ai_chat(array $messages, array $options = []): array {
$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',
];
}
@@ -81,7 +702,7 @@ function fociz_ai_chat(array $messages, array $options = []): array {
* - 'Oplossingen'
* - 'Quick Wins'
*/
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards): array {
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards, ?int $userId = null): array {
if (empty($cards)) {
return ['success' => true, 'categorized' => []];
}
@@ -109,7 +730,7 @@ function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $c
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['json_mode' => true]);
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren'];
@@ -121,28 +742,89 @@ function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $c
/**
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
*/
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4): array {
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4, ?int $userId = null, 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"
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een gele post-it staan).\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."],
], ['json_mode' => true]);
['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'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
$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 = ''): array {
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '', ?int $userId = null): array {
$systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n"
. "Retourneer een JSON-object met:\n"
. "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n"
@@ -154,39 +836,72 @@ function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"],
], ['json_mode' => true]);
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren'];
// 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 bestaande afspraken
* Berekent een slimme dagindeling voor open taken rondom geanonimiseerde bezette tijdblokken.
* ZERO-DATA LEAKAGE: Bevat NOOIT meetingtitels, inhoud of e-mailadressen!
*/
function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySettings): array {
function fociz_ai_smart_schedule(array $tasks, array $busyBlocks, array $workdaySettings, ?int $userId = null): array {
$startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0];
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:30')[0];
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:00')[0];
$lunchStart = $workdaySettings['lunch_start'] ?? '12:30';
$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00';
// Anonimiseer taken: stuur alleen ID, titel (gefilterd door firewall), prioriteit en duur
$sanitizedTasks = array_map(fn($t) => [
'id' => (int)$t['id'],
'title' => (string)$t['title'],
'priority' => $t['priority'] ?? 'P2',
'duration_minutes' => (int)($t['duration_minutes'] ?? 30),
], $tasks);
// Anonimiseer bezette blokken: GEEN titels of deelnemers, enkel tijdstippen!
$sanitizedBusy = array_map(fn($b) => [
'start' => $b['start'] ?? '09:00',
'end' => $b['end'] ?? '10:00',
], $busyBlocks);
$systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n"
. "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n"
. "Regels:\n"
. "1. Plan GEEN taken tijdens bestaande afspraken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
. "1. Plan GEEN taken tijdens bezette blokken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
. "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n"
. "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n"
. "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}.";
$userPrompt = "Bestaande afspraken:\n" . json_encode($events, JSON_UNESCAPED_UNICODE) . "\n\n"
. "In te plannen taken:\n" . json_encode($tasks, JSON_UNESCAPED_UNICODE);
$userPrompt = "Bezette tijdsintervallen (agenda):\n" . json_encode($sanitizedBusy, JSON_UNESCAPED_UNICODE) . "\n\n"
. "In te plannen taken:\n" . json_encode($sanitizedTasks, JSON_UNESCAPED_UNICODE);
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['json_mode' => true]);
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen'];
@@ -195,3 +910,74 @@ function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySett
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',
];
}
+992 -52
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
<?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!'
];
}
+160
View File
@@ -0,0 +1,160 @@
<?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();
}
+2 -6
View File
@@ -14,13 +14,9 @@
require_once __DIR__ . '/../config/config.php';
// Standaard waarde voor de main branch: 'live' (kan overschreven worden via APP_ENV)
// Standaard waarde voor de main branch: 'live' (bepaald via centrale config.php)
$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']);
?>
@@ -56,7 +52,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.2 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.5 rounded bg-red-500/20 text-red-300 border border-red-500/30">
DEV
</span>
</div>
+43
View File
@@ -16,6 +16,49 @@
<!-- 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') {
+184 -40
View File
@@ -7,14 +7,22 @@ require_once __DIR__ . '/auth.php';
$currentUser = $currentUser ?? require_auth();
$currentPage = basename($_SERVER['PHP_SELF']);
$currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark';
// 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, Dagplanning & Roadmap') ?></title>
<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() {
@@ -61,33 +69,28 @@ $currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUse
<script>
(function() {
try {
const sessionDate = "<?= date('Y-m-d') ?>";
const checkMidnight = function() {
const now = new Date();
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
const todayStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
if (todayStr !== sessionDate || now.getTime() > midnight.getTime()) {
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 now = new Date();
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
const msUntilMidnight = midnight.getTime() - now.getTime();
if (msUntilMidnight > 0) {
setTimeout(function() {
window.location.href = '/auth/login.php?expired=1';
}, msUntilMidnight);
const msUntilExpiry = sessionExpiresAt - Date.now();
if (msUntilExpiry > 0) {
setTimeout(checkExpiry, msUntilExpiry + 500);
}
// Controleer direct zodra de gebruiker de browser weer activeert (bij bijv. standby / slaapstand overnacht)
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
checkMidnight();
checkExpiry();
}
});
window.addEventListener('focus', checkMidnight);
setInterval(checkMidnight, 30000);
window.addEventListener('focus', checkExpiry);
setInterval(checkExpiry, 60000);
} catch(e) {}
})();
</script>
@@ -151,9 +154,9 @@ require_once __DIR__ . '/env_badge.php';
<header class="sticky top-0 z-40 bg-white/80 dark:bg-[#0c0f17]/85 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800/60 shadow-xs transition-colors duration-150">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Brand Logo (met linker padding zodat het dev/live badge vrij staat) -->
<div class="flex items-center space-x-6 pl-28 sm:pl-36">
<a href="/planner.php" class="flex items-center space-x-2.5 group">
<!-- 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">
F
</div>
@@ -166,20 +169,78 @@ require_once __DIR__ . '/env_badge.php';
<!-- Nav Tabs -->
<nav class="hidden md:flex items-center space-x-1">
<a href="/planner.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'planner.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>Dagplanning</span>
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
<span>Fociz</span>
</a>
<a href="/brainstorm.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<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">
<i data-lucide="lightbulb" class="w-4 h-4"></i>
<span>Brainstorm</span>
</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>
</a>
<a href="/roadmap.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'roadmap.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<!-- 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">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Roadmap</span>
</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>
</a>
<a href="/profile.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'profile.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="user" class="w-4 h-4"></i>
<span>Mijn Profiel</span>
</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>
<?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' ?>">
@@ -194,14 +255,85 @@ require_once __DIR__ . '/env_badge.php';
</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>
</div>
<!-- Divider -->
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700 mx-0.5 shrink-0 self-center"></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">
<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" 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">
<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">
@@ -215,7 +347,7 @@ require_once __DIR__ . '/env_badge.php';
</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">
<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">
<i data-lucide="log-out" class="w-4 h-4"></i>
</a>
</div>
@@ -224,8 +356,19 @@ require_once __DIR__ . '/env_badge.php';
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-200/80 dark:border-slate-800/60 text-xs">
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="calendar" class="w-4 h-4 mb-0.5"></i>
<span>Planning</span>
<i data-lucide="layout-dashboard" class="w-4 h-4 mb-0.5"></i>
<span>Fociz</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' ?>">
<i data-lucide="lightbulb" class="w-4 h-4 mb-0.5"></i>
@@ -235,9 +378,9 @@ require_once __DIR__ . '/env_badge.php';
<i data-lucide="map" class="w-4 h-4 mb-0.5"></i>
<span>Roadmap</span>
</a>
<a href="/profile.php" class="flex flex-col items-center py-1 <?= $currentPage === 'profile.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="user" class="w-4 h-4 mb-0.5"></i>
<span>Profiel</span>
<a 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' ?>">
<i data-lucide="layers" class="w-4 h-4 mb-0.5"></i>
<span>Jira</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' ?>">
@@ -423,4 +566,5 @@ function focizQuickToggleTheme() {
}
</script>
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
<?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">
+2 -1
View File
@@ -70,7 +70,8 @@ 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, 6);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(4, max(1, $timeout - 1)));
curl_setopt($ch, CURLOPT_DNS_CACHE_TIMEOUT, 600);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+648
View File
@@ -0,0 +1,648 @@
<?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} &rarr;
</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} &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;
</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;'>
&copy; {$currentYear} Fociz &bull; Veilige Project- &amp; 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));
}
}
+252 -29
View File
@@ -29,36 +29,54 @@ function ensure_system_settings_table(?PDO $db = null): void {
}
/**
* Haalt een enkele instelling op uit MariaDB
* Haalt alle systeeminstellingen in 1 geoptimaliseerde query op en bewaart deze in het static runtime geheugen
*/
function get_system_setting(string $key, $default = null) {
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->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?");
$stmt->execute([$key]);
$val = $stmt->fetchColumn();
if ($val === false || $val === null) {
return $default;
$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;
}
// Decodeer JSON indien van toepassing
$trimmed = trim((string)$val);
if (str_starts_with($trimmed, '{') || str_starts_with($trimmed, '[')) {
$decoded = json_decode($val, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
$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 $val;
} catch (Throwable $e) {
error_log("get_system_setting error for {$key}: " . $e->getMessage());
return $default;
}
return $settingsCache;
}
/**
* Slaat een instelling op in MariaDB
* 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 {
@@ -72,7 +90,11 @@ function set_system_setting(string $key, $value): bool {
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()
");
return $stmt->execute([$key, $valToStore]);
$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;
@@ -83,32 +105,233 @@ function set_system_setting(string $key, $value): bool {
* 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: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 v1.5)
* Haalt AI configuratie-instellingen op (Release v2.0 - Google Gemini Pro & OpenAI)
* Ondersteunt organisatiebrede en optioneel persoonlijke sleutels.
*/
function get_ai_settings(): array {
$envKey = fociz_env('OPENAI_API_KEY', '');
$savedKey = get_system_setting('ai_api_key', '');
$apiKey = !empty($savedKey) ? $savedKey : $envKey;
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', !empty($apiKey)),
'ai_provider' => get_system_setting('ai_provider', 'openai'),
'ai_enabled' => (bool)get_system_setting('ai_enabled', $hasKey),
'ai_provider' => (string)$provider,
'ai_api_key' => $apiKey,
'ai_model' => get_system_setting('ai_model', 'gpt-4o-mini'),
'has_key' => !empty($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;
}
+18 -6
View File
@@ -107,14 +107,12 @@ class TOTP {
}
/**
* Genereert een QR code afbeelding URL (via QuickChart HTTPS)
* Genereert een veilige client-side QR code (geen externe API calls meer ter bescherming van TOTP secrets)
*/
public static function getQrCodeUrl(string $otpAuthUrl, int $size = 220): string {
return sprintf(
'https://quickchart.io/qr?text=%s&size=%d&margin=1&ecLevel=M',
urlencode($otpAuthUrl),
$size
);
// 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 '';
}
/**
@@ -130,3 +128,17 @@ 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);
}
}
+3371
View File
File diff suppressed because it is too large Load Diff
+1641
View File
File diff suppressed because it is too large Load Diff
+7110 -482
View File
File diff suppressed because it is too large Load Diff
+629 -21
View File
@@ -25,6 +25,10 @@ if (!empty($_GET['success'])) {
// POST Verwerking VÓÓR headers zodat redirects en mutaties soepel verlopen
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
$message = "Beveiligingsfout: CSRF-token ongeldig of verlopen. Ververs de pagina en probeer opnieuw.";
$messageType = 'error';
} else {
$action = $_POST['action'] ?? '';
// 1. Outlook koppelen via OAuth
@@ -79,7 +83,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
if (($jiraToken === '' || $jiraToken === '••••••••••••••••') && !empty($existingSettings['jira_api_token']) && $existingSettings['jira_api_token'] !== '••••••••••••••••') {
$jiraToken = $existingSettings['jira_api_token'];
$jiraToken = fociz_decrypt($existingSettings['jira_api_token']);
}
$accountName = $existingSettings['account_name'] ?? '';
@@ -104,7 +108,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$newSettings = [
'jira_domain' => $jiraDomain,
'jira_email' => $jiraEmail,
'jira_api_token' => $jiraToken,
'jira_api_token' => fociz_encrypt($jiraToken),
'account_name' => $accountName,
'account_id' => $accountId,
];
@@ -128,6 +132,48 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
// 4b. Persoonlijke Google Gemini / AI instellingen opslaan
elseif ($action === 'save_personal_ai') {
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
$aiProvider = trim($_POST['ai_provider'] ?? 'gemini');
$aiModel = trim($_POST['ai_model'] ?? 'gemini-2.0-flash');
$apiKeyIn = trim($_POST['ai_api_key'] ?? '');
// Haal eventueel bestaande integratie op
$stmtEx = $db->prepare("SELECT id, access_token, settings FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') LIMIT 1");
$stmtEx->execute([$userId]);
$existing = $stmtEx->fetch();
$tokenToSave = $existing['access_token'] ?? null;
if ($apiKeyIn !== '' && $apiKeyIn !== '••••••••••••••••') {
$tokenToSave = fociz_encrypt($apiKeyIn);
}
$settingsJson = json_encode([
'provider' => $aiProvider,
'model' => $aiModel,
]);
if ($existing) {
$stmtUp = $db->prepare("UPDATE integrations SET provider = 'GEMINI', is_enabled = ?, access_token = ?, settings = ?, last_sync_at = NOW() WHERE id = ?");
$stmtUp->execute([$aiEnabled, $tokenToSave, $settingsJson, $existing['id']]);
} else {
$stmtIn = $db->prepare("INSERT INTO integrations (user_id, provider, is_enabled, access_token, settings, last_sync_at) VALUES (?, 'GEMINI', ?, ?, ?, NOW())");
$stmtIn->execute([$userId, $aiEnabled, $tokenToSave, $settingsJson]);
}
$message = "Uw persoonlijke AI instellingen zijn succesvol opgeslagen.";
$messageType = 'success';
}
// 4c. Persoonlijke AI sleutel ontkoppelen / verwijderen
elseif ($action === 'disconnect_personal_ai') {
$stmtDel = $db->prepare("DELETE FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI')");
$stmtDel->execute([$userId]);
$message = "Uw persoonlijke AI sleutel is ontkoppeld. Fociz gebruikt nu de centrale instellingen van de organisatie.";
$messageType = 'success';
}
// 5. Notificatievoorkeuren opslaan
elseif ($action === 'save_notifications') {
$settings = [
@@ -171,17 +217,84 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!password_verify($currentPassword, $storedHash)) {
$message = "Uw huidige wachtwoord is niet correct ingevoerd.";
$messageType = 'error';
} elseif (strlen($newPassword) < 8) {
$message = "Het nieuwe wachtwoord moet minimaal 8 tekens lang zijn.";
} elseif (($pwErr = fociz_validate_password($newPassword)) !== null) {
$message = $pwErr;
$messageType = 'error';
} elseif ($newPassword !== $confirmPassword) {
$message = "Het nieuwe wachtwoord en de bevestiging komen niet overeen.";
$messageType = 'error';
} else {
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
$stmtUp = $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
$stmtUp->execute([$newHash, $userId]);
$message = "Uw wachtwoord is succesvol gewijzigd.";
// SOC 2 CC6.1 / ISO 27001 A.9.4.2: Trek overige sessies in bij wachtwoordwijziging
fociz_revoke_other_sessions($userId, session_id());
fociz_log_audit($userId, 'PASSWORD_CHANGED', 'user', $userId, ['method' => 'profile'], 'SUCCESS', $user['email']);
$message = "Uw wachtwoord is succesvol gewijzigd. Alle overige sessies op andere apparaten zijn uit veiligheidsoverwegingen beëindigd.";
$messageType = 'success';
}
}
// 7b. Alle overige sessies op afstand beëindigen (SOC 2 / ISO 27001)
elseif ($action === 'revoke_other_sessions') {
$count = fociz_revoke_other_sessions($userId, session_id());
$message = "Succesvol {$count} andere actieve sessie(s) op afstand beëindigd.";
$messageType = 'success';
}
// 8. Standaard avatar kiezen uit bibliotheek
elseif ($action === 'select_preset_avatar') {
$preset = trim($_POST['avatar_preset'] ?? '');
if (preg_match('/^\/assets\/avatars\/avatar-(0[1-9]|1[0-9]|2[0-4])\.svg$/', $preset)) {
$stmtUp = $db->prepare("UPDATE users SET avatar_url = ? WHERE id = ?");
$stmtUp->execute([$preset, $userId]);
$_SESSION['user_avatar_url'] = $preset;
$user['avatar_url'] = $preset;
$message = "Uw avatar is succesvol bijgewerkt!";
$messageType = 'success';
} else {
$message = "Ongeldige avatar geselecteerd.";
$messageType = 'error';
}
}
// 9. Eigen profielfoto uploaden
elseif ($action === 'upload_custom_avatar') {
if (!isset($_FILES['avatar_file']) || $_FILES['avatar_file']['error'] !== UPLOAD_ERR_OK) {
$message = "Selecteer een geldige afbeelding om te uploaden.";
$messageType = 'error';
} else {
$file = $_FILES['avatar_file'];
$maxSize = 4 * 1024 * 1024; // 4MB
if ($file['size'] > $maxSize) {
$message = "De afbeelding is te groot (maximaal 4MB toegestaan).";
$messageType = 'error';
} else {
$processed = fociz_process_avatar_upload($file, $userId);
if (!$processed['success']) {
$message = $processed['error'] ?? "Fout bij verwerken van de afbeelding.";
$messageType = 'error';
} else {
$avatarUri = $processed['data_uri'];
$stmtUp = $db->prepare("UPDATE users SET avatar_url = ? WHERE id = ?");
$stmtUp->execute([$avatarUri, $userId]);
$_SESSION['user_avatar_url'] = $avatarUri;
$user['avatar_url'] = $avatarUri;
$message = "Uw profielfoto is succesvol geüpload en permanent bewaard!";
$messageType = 'success';
}
}
}
}
// 10. Avatar verwijderen
elseif ($action === 'remove_avatar') {
$stmtUp = $db->prepare("UPDATE users SET avatar_url = NULL WHERE id = ?");
$stmtUp->execute([$userId]);
unset($_SESSION['user_avatar_url']);
$user['avatar_url'] = null;
$message = "Uw avatar is teruggezet naar de standaard weergave met uw initialen.";
$messageType = 'success';
}
}
@@ -191,6 +304,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmtUser = $db->prepare("SELECT * FROM users WHERE id = ?");
$stmtUser->execute([$userId]);
$userProfile = $stmtUser->fetch();
if ($userProfile) {
$userProfile['avatar_url'] = fociz_get_valid_avatar_url($userProfile['avatar_url'] ?? null, $userId);
}
// Outlook status ophalen
$stmtOutlook = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
@@ -214,9 +330,20 @@ $appConfig = microsoft_get_app_config();
// Notificatie-instellingen
$notifSettings = get_user_notification_settings($userId);
// AI Co-Pilot & Google Gemini instellingen voor gebruiker
$userAiSettings = get_ai_settings($userId);
$stmtAiInt = $db->prepare("SELECT id, access_token, is_enabled, settings FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') LIMIT 1");
$stmtAiInt->execute([$userId]);
$userAiIntRow = $stmtAiInt->fetch();
$hasPersonalAiKey = !empty($userAiIntRow['access_token']);
$userAiRowSettings = json_decode($userAiIntRow['settings'] ?? '{}', true) ?: [];
// Thema ophalen
$userTheme = get_user_theme($userId);
// Actieve apparaten & sessies ophalen (SOC 2 / ISO 27001)
$activeSessions = fociz_get_user_active_sessions($userId, session_id());
$pageTitle = 'Mijn Profiel & Koppelingen — Fociz';
require_once __DIR__ . '/includes/header.php';
?>
@@ -228,10 +355,17 @@ require_once __DIR__ . '/includes/header.php';
<div class="relative z-10 flex flex-col sm:flex-row sm:items-center justify-between gap-6">
<div class="flex items-center space-x-4 sm:space-x-5">
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 shadow-sm flex-shrink-0">
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 shadow-sm flex-shrink-0 overflow-hidden">
<?php if (!empty($userProfile['avatar_url'])): ?>
<img src="<?= htmlspecialchars($userProfile['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-2xl object-cover bg-white dark:bg-[#0c0f17]" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
<div class="hidden w-full h-full rounded-2xl bg-white dark:bg-[#0c0f17] flex items-center justify-center font-bold text-2xl sm:text-3xl text-indigo-600 dark:text-indigo-400 uppercase">
<?= strtoupper(substr($userProfile['name'], 0, 1)) ?>
</div>
<?php else: ?>
<div class="w-full h-full rounded-2xl bg-white dark:bg-[#0c0f17] flex items-center justify-center font-bold text-2xl sm:text-3xl text-indigo-600 dark:text-indigo-400 uppercase">
<?= strtoupper(substr($userProfile['name'], 0, 1)) ?>
</div>
<?php endif; ?>
</div>
<div class="space-y-1">
@@ -419,6 +553,138 @@ require_once __DIR__ . '/includes/header.php';
</div>
</div>
<!-- Sectie 0b: Profielfoto & Avatar Beheer -->
<div class="fociz-card p-6 sm:p-7 space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-2xl bg-gradient-to-tr from-indigo-500 to-pink-500 p-0.5 shadow-xs flex-shrink-0 flex items-center justify-center text-white">
<i data-lucide="smile" class="w-6 h-6"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Profielfoto & Avatar</h2>
<span class="px-2.5 py-0.5 rounded-full <?= !empty($userProfile['avatar_url']) ? 'bg-teal-50 dark:bg-teal-500/10 border border-teal-200 dark:border-teal-500/20 text-teal-700 dark:text-teal-400' : 'bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400' ?> text-[10px] font-semibold">
<?= !empty($userProfile['avatar_url']) ? 'Aangepaste Avatar Actief' : 'Standaard Initiaal-Badge' ?>
</span>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">Kies een mini-avatar uit de 24 stijlvolle karakters of upload een eigen afbeelding.</p>
</div>
</div>
<?php if (!empty($userProfile['avatar_url'])): ?>
<form method="POST" action="/profile.php" class="self-start sm:self-auto">
<input type="hidden" name="action" value="remove_avatar">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<button type="submit" onclick="return confirm('Avatar herstellen naar standaard initiaal-badge?')" class="inline-flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800/50 hover:bg-rose-100 dark:hover:bg-rose-900/50 transition cursor-pointer">
<i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i>
<span>Standaard Herstellen</span>
</button>
</form>
<?php endif; ?>
</div>
<!-- Avatar Selector Grid & Upload -->
<div class="mt-6 grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
<!-- Linker kolom: 24 mini karakters in grid (8 cols) -->
<div class="lg:col-span-8 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-xs font-bold text-slate-800 dark:text-slate-200 flex items-center space-x-1.5">
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-indigo-500"></i>
<span>Kies een Karakter (24 Stijlen)</span>
</h3>
<!-- Categorie Filters -->
<div class="flex items-center space-x-1 bg-slate-100 dark:bg-[#0c0f17] p-1 rounded-xl border border-slate-200 dark:border-slate-800 text-[11px]" id="avatarFilterBar">
<button type="button" onclick="filterAvatars('all')" class="avatar-filter-btn active px-2.5 py-1 rounded-lg font-medium transition cursor-pointer" data-cat="all">Alles</button>
<button type="button" onclick="filterAvatars('pro')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="pro">Pro</button>
<button type="button" onclick="filterAvatars('creative')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="creative">Creatief</button>
<button type="button" onclick="filterAvatars('tech')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="tech">Tech</button>
<button type="button" onclick="filterAvatars('heroes')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="heroes">Heroes</button>
</div>
</div>
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2.5 p-3 rounded-2xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800" id="avatarGrid">
<?php
$avatarPresets = [
['file' => 'avatar-01.svg', 'cat' => 'pro', 'name' => 'Alex (Business)'],
['file' => 'avatar-02.svg', 'cat' => 'pro', 'name' => 'Sophie (Corporate)'],
['file' => 'avatar-03.svg', 'cat' => 'pro', 'name' => 'Liam (Consultant)'],
['file' => 'avatar-04.svg', 'cat' => 'pro', 'name' => 'Emma (Manager)'],
['file' => 'avatar-05.svg', 'cat' => 'pro', 'name' => 'Daan (Director)'],
['file' => 'avatar-06.svg', 'cat' => 'pro', 'name' => 'Noa (Executive)'],
['file' => 'avatar-07.svg', 'cat' => 'creative', 'name' => 'Mila (Designer)'],
['file' => 'avatar-08.svg', 'cat' => 'creative', 'name' => 'Lucas (Creator)'],
['file' => 'avatar-09.svg', 'cat' => 'creative', 'name' => 'Zara (Artist)'],
['file' => 'avatar-10.svg', 'cat' => 'creative', 'name' => 'Finn (Illustrator)'],
['file' => 'avatar-11.svg', 'cat' => 'creative', 'name' => 'Lotte (Writer)'],
['file' => 'avatar-12.svg', 'cat' => 'creative', 'name' => 'Sem (Architect)'],
['file' => 'avatar-13.svg', 'cat' => 'tech', 'name' => 'Bram (Developer)'],
['file' => 'avatar-14.svg', 'cat' => 'tech', 'name' => 'Julia (Engineer)'],
['file' => 'avatar-15.svg', 'cat' => 'tech', 'name' => 'Jesse (DevOps)'],
['file' => 'avatar-16.svg', 'cat' => 'tech', 'name' => 'Tess (Data Scientist)'],
['file' => 'avatar-17.svg', 'cat' => 'tech', 'name' => 'Milan (Cyber Sec)'],
['file' => 'avatar-18.svg', 'cat' => 'tech', 'name' => 'Liv (AI Engineer)'],
['file' => 'avatar-19.svg', 'cat' => 'heroes', 'name' => 'Max (Ninja)'],
['file' => 'avatar-20.svg', 'cat' => 'heroes', 'name' => 'Sara (Astronaut)'],
['file' => 'avatar-21.svg', 'cat' => 'heroes', 'name' => 'Robin (Pilot)'],
['file' => 'avatar-22.svg', 'cat' => 'heroes', 'name' => 'Eva (Explorer)'],
['file' => 'avatar-23.svg', 'cat' => 'heroes', 'name' => 'Leo (Cosmic)'],
['file' => 'avatar-24.svg', 'cat' => 'heroes', 'name' => 'Nova (Cyberpunk)'],
];
foreach ($avatarPresets as $av):
$avatarPath = "/assets/avatars/{$av['file']}";
$isSelected = ($userProfile['avatar_url'] === $avatarPath);
?>
<form method="POST" action="/profile.php" class="relative group avatar-item" data-category="<?= $av['cat'] ?>">
<input type="hidden" name="action" value="select_preset_avatar">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="avatar_preset" value="<?= $avatarPath ?>">
<button type="submit" class="w-full aspect-square rounded-2xl p-1 transition-all duration-200 transform hover:scale-110 active:scale-95 hover:shadow-lg hover:shadow-indigo-500/25 focus:outline-none cursor-pointer <?= $isSelected ? 'ring-2 ring-indigo-600 dark:ring-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 shadow-sm' : 'hover:bg-white dark:hover:bg-slate-800 border border-transparent' ?>" title="<?= htmlspecialchars($av['name']) ?>">
<img src="<?= $avatarPath ?>" alt="<?= htmlspecialchars($av['name']) ?>" class="w-full h-full rounded-xl object-contain pointer-events-none drop-shadow-xs">
<?php if ($isSelected): ?>
<span class="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-indigo-600 text-white flex items-center justify-center shadow-xs border-2 border-white dark:border-[#0c0f17]">
<i data-lucide="check" class="w-3 h-3 stroke-[3]"></i>
</span>
<?php endif; ?>
</button>
</form>
<?php endforeach; ?>
</div>
</div>
<!-- Rechter kolom: Eigen Afbeelding Uploaden (4 cols) -->
<div class="lg:col-span-4 space-y-3">
<h3 class="text-xs font-bold text-slate-800 dark:text-slate-200 flex items-center space-x-1.5">
<i data-lucide="upload-cloud" class="w-3.5 h-3.5 text-sky-500"></i>
<span>Eigen Foto Uploaden</span>
</h3>
<form method="POST" action="/profile.php" enctype="multipart/form-data" class="p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 space-y-4">
<input type="hidden" name="action" value="upload_custom_avatar">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div class="text-center p-5 border-2 border-dashed border-slate-300 dark:border-slate-700 rounded-xl hover:border-indigo-500 dark:hover:border-indigo-400 transition bg-white dark:bg-[#111622]">
<div class="w-10 h-10 mx-auto mb-2 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
<i data-lucide="image" class="w-5 h-5"></i>
</div>
<label for="customAvatarFile" class="block text-xs font-semibold text-indigo-600 dark:text-indigo-400 hover:underline cursor-pointer mb-1">
Klik om bestand te kiezen
</label>
<p class="text-[10px] text-slate-500 dark:text-slate-400">JPG, PNG, WebP of GIF (max. 4MB)</p>
<input type="file" id="customAvatarFile" name="avatar_file" accept="image/png, image/jpeg, image/webp, image/gif" required class="hidden" onchange="previewUploadAvatar(this)">
<div id="uploadFileName" class="mt-2 text-[11px] font-mono text-slate-700 dark:text-slate-300 truncate hidden"></div>
</div>
<button type="submit" id="uploadAvatarBtn" 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 flex items-center justify-center space-x-2 cursor-pointer">
<i data-lucide="upload" class="w-4 h-4"></i>
<span>Foto Uploaden &amp; Opslaan</span>
</button>
</form>
</div>
</div>
</div>
<!-- Sectie 1: Mijn Microsoft 365 Outlook Agenda -->
<div class="fociz-card p-6 sm:p-7">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
@@ -428,7 +694,7 @@ require_once __DIR__ . '/includes/header.php';
</div>
<div>
<div class="flex items-center space-x-2">
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Mijn Microsoft 365 Agenda</h2>
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Mijn Microsoft 365 (Agenda & E-mail)</h2>
<?php if ($isOutlookConnected): ?>
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-500/15 border border-teal-200 dark:border-teal-500/30 text-teal-700 dark:text-teal-400 text-[10px] font-semibold flex items-center space-x-1">
<span class="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse"></span>
@@ -440,17 +706,18 @@ require_once __DIR__ . '/includes/header.php';
</span>
<?php endif; ?>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">Synchroniseer uw agenda-afspraken automatisch met uw dagplanning</p>
<p class="text-xs text-slate-500 dark:text-slate-400">Synchroniseer uw agenda-afspraken en zakelijke Outlook e-mails met Fociz</p>
</div>
</div>
<?php if ($isOutlookConnected): ?>
<div class="flex items-center space-x-2">
<a href="/auth/oauth_microsoft.php" class="px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700 text-xs font-medium transition" title="Opnieuw autoriseren bij Microsoft">
Opnieuw Verbinden
<a href="/auth/oauth_microsoft.php?reconsent=1" class="px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700 text-xs font-medium transition" title="Machtigingen bijwerken bij Microsoft (inclusief Mail.ReadWrite)">
Machtigingen Bijwerken
</a>
<form method="POST" onsubmit="return confirm('Weet u zeker dat u uw Microsoft Outlook agenda wilt ontkoppelen?');">
<input type="hidden" name="action" value="disconnect_outlook">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<button type="submit" class="px-3 py-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-500/10 dark:hover:bg-rose-500/20 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20 text-xs font-medium transition cursor-pointer">
Ontkoppelen
</button>
@@ -480,6 +747,7 @@ require_once __DIR__ . '/includes/header.php';
<form method="POST" class="mt-4 p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<input type="hidden" name="action" value="save_outlook_settings">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div class="space-y-1">
<label for="sync_interval_seconds" class="text-xs font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5 text-sky-500"></i>
@@ -529,6 +797,7 @@ require_once __DIR__ . '/includes/header.php';
<div>
<form method="POST">
<input type="hidden" name="action" value="connect_outlook">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<button type="submit" class="inline-flex items-center space-x-2 px-6 py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs transition shadow-xs cursor-pointer">
<i data-lucide="log-in" class="w-4 h-4"></i>
<span>Koppel Mijn Outlook Agenda</span>
@@ -579,6 +848,7 @@ require_once __DIR__ . '/includes/header.php';
<?php if ($isJiraConnected): ?>
<form method="POST" onsubmit="return confirm('Weet u zeker dat u Jira wilt ontkoppelen?');">
<input type="hidden" name="action" value="disconnect_jira">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<button type="submit" class="px-3 py-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-500/10 dark:hover:bg-rose-500/20 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20 text-xs font-medium transition cursor-pointer">
Jira Ontkoppelen
</button>
@@ -588,6 +858,7 @@ require_once __DIR__ . '/includes/header.php';
<form method="POST" class="mt-6 space-y-4">
<input type="hidden" name="action" value="save_jira">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
@@ -618,7 +889,7 @@ require_once __DIR__ . '/includes/header.php';
</div>
<div class="relative">
<input type="password" id="jiraApiTokenInput" name="jira_api_token" value="<?= !empty($jiraSettings['jira_api_token']) ? '••••••••••••••••' : '' ?>" placeholder="Plak hier uw Atlassian API token..." class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl pl-3.5 pr-10 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<button type="button" onclick="toggleJiraToken()" class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
<button type="button" onclick="toggleJiraToken()" id="toggleJiraTokenBtn" title="Toon / verberg token" class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
<i data-lucide="eye" class="w-4 h-4" id="eyeJiraIcon"></i>
</button>
</div>
@@ -641,6 +912,138 @@ require_once __DIR__ . '/includes/header.php';
</form>
</div>
<!-- Sectie 2b: Mijn AI Co-Pilot & Google Gemini Pro -->
<div class="fociz-card p-6 sm:p-7">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs">
<i data-lucide="sparkles" class="w-6 h-6"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">AI Co-Pilot & Google Gemini Pro</h2>
<?php if ($hasPersonalAiKey): ?>
<span class="px-2.5 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-500/15 border border-indigo-200 dark:border-indigo-500/30 text-indigo-700 dark:text-indigo-400 text-[10px] font-semibold">
Eigen Sleutel
</span>
<?php elseif (!empty($userAiSettings['has_key'])): ?>
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-500/15 border border-teal-200 dark:border-teal-500/30 text-teal-700 dark:text-teal-400 text-[10px] font-semibold">
Organisatiesleutel Actief
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-amber-700 dark:text-amber-400 text-[10px] font-semibold">
Geen Sleutel
</span>
<?php endif; ?>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Koppel uw eigen Google Gemini Pro account of gebruik de centrale organisatieconfiguratie</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/60 text-xs font-bold flex items-center space-x-1">
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i>
<span>Zero-Data Leakage Actief</span>
</span>
</div>
</div>
<!-- Privacy Shield Banner -->
<div class="mt-6 p-3.5 rounded-2xl bg-emerald-50/70 dark:bg-emerald-950/20 border border-emerald-200/70 dark:border-emerald-800/40 flex items-start space-x-3">
<div class="w-7 h-7 rounded-xl bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 flex items-center justify-center flex-shrink-0 mt-0.5">
<i data-lucide="shield-check" class="w-4 h-4"></i>
</div>
<div class="text-xs">
<strong class="text-emerald-900 dark:text-emerald-200 font-bold block">100% Zero-Data Leakage Garantie</strong>
<p class="text-emerald-800/80 dark:text-emerald-300/80 text-[11px] mt-0.5">
Uw gevoelige gegevens uit <strong>Jira tickets</strong>, <strong>Outlook e-mails</strong> en <strong>Microsoft Teams</strong> worden strikt afgeschermd en <strong>NOOIT</strong> naar Google Gemini of OpenAI verstuurd.
</p>
</div>
</div>
<form method="POST" action="/profile.php" class="mt-6 space-y-4">
<input type="hidden" name="action" value="save_personal_ai">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div class="p-3.5 rounded-2xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800">
<label class="flex items-center space-x-2.5 text-xs text-slate-900 dark:text-white cursor-pointer select-none">
<input type="checkbox" name="ai_enabled" value="1" <?= (!isset($userAiIntRow['is_enabled']) || !empty($userAiIntRow['is_enabled'])) ? 'checked' : '' ?> class="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700">
<span class="font-semibold">AI Co-Pilot assistentie inschakelen voor mijn account</span>
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
<select name="ai_model" id="profile_ai_model" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<optgroup label="Google Gemini (Aanbevolen)">
<option value="gemini-2.0-flash" <?= ($userAiSettings['ai_model'] === 'gemini-2.0-flash' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Aanbevolen, Razendsnel)</option>
<option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro" <?= $userAiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro</option>
</optgroup>
<optgroup label="OpenAI">
<option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
<option value="gpt-4o" <?= $userAiSettings['ai_model'] === 'gpt-4o' ? 'selected' : '' ?>>OpenAI GPT-4o</option>
</optgroup>
</select>
<input type="hidden" name="ai_provider" id="profile_ai_provider" value="<?= strpos($userAiSettings['ai_model'], 'gpt') !== false ? 'openai' : 'gemini' ?>">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Persoonlijke Google Gemini API Sleutel
<span class="text-[11px] font-normal text-slate-500 dark:text-slate-400">(Optioneel)</span>
</label>
<div class="relative">
<input
type="password"
name="ai_api_key"
id="profile_ai_api_key"
value="<?= $hasPersonalAiKey ? '••••••••••••••••' : '' ?>"
placeholder="<?= $userAiSettings['has_key'] ? 'Gebruikt organisatie-sleutel (leeg laten)' : 'AIzaSy... (Google AI Studio)' ?>"
class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl pl-3.5 pr-9 py-2.5 text-xs text-slate-900 dark:text-white font-mono placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<button type="button" onclick="togglePasswordVisibility('profile_ai_api_key', this)" class="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
</button>
</div>
<span class="block text-[11px] text-slate-500 dark:text-slate-400 mt-1">
Gratis sleutel aanmaken? <a href="https://aistudio.google.com/app/apikey" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">Google AI Studio &rarr;</a>
</span>
</div>
</div>
<!-- Live Test Resultaat Alert in Profiel -->
<div id="profileAiTestResult" class="hidden p-3 rounded-xl text-xs"></div>
<div class="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-2">
<button type="button" onclick="testPersonalAiConnection()" id="profileAiTestBtn" class="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 text-xs font-semibold transition cursor-pointer flex items-center space-x-2">
<i data-lucide="activity" class="w-3.5 h-3.5 text-indigo-500"></i>
<span id="profileAiTestBtnText">Test Verbinding</span>
</button>
<?php if ($hasPersonalAiKey): ?>
<button type="submit" form="disconnectPersonalAiForm" class="px-3 py-2 rounded-xl text-xs text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 transition cursor-pointer font-medium">
Persoonlijke Sleutel Verwijderen
</button>
<?php endif; ?>
</div>
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition shadow-xs cursor-pointer">
AI Instellingen Opslaan
</button>
</div>
</form>
<?php if ($hasPersonalAiKey): ?>
<form method="POST" id="disconnectPersonalAiForm" class="hidden">
<input type="hidden" name="action" value="disconnect_personal_ai">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
</form>
<?php endif; ?>
</div>
<!-- Sectie 3: Mijn Meldingen & Agenda Push Notificaties -->
<div class="fociz-card p-6 sm:p-7">
<div class="flex items-center space-x-3.5 pb-6 border-b border-slate-100 dark:border-slate-800/80">
@@ -689,6 +1092,7 @@ require_once __DIR__ . '/includes/header.php';
<form method="POST" class="space-y-4">
<input type="hidden" name="action" value="save_notifications">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div class="flex items-center justify-between p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800">
<div>
@@ -798,6 +1202,7 @@ require_once __DIR__ . '/includes/header.php';
<form method="POST" class="space-y-4">
<input type="hidden" name="action" value="update_profile">
<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.5">
@@ -831,6 +1236,7 @@ require_once __DIR__ . '/includes/header.php';
<form method="POST" class="space-y-3">
<input type="hidden" name="action" value="change_password">
<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">
@@ -841,16 +1247,17 @@ require_once __DIR__ . '/includes/header.php';
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
Nieuw Wachtwoord (min. 8 tekens)
Nieuw Wachtwoord (min. 12 tekens)
</label>
<input type="password" name="new_password" required minlength="8" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<input type="password" name="new_password" required minlength="12" placeholder="Minimaal 12 tekens" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<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>
<input type="password" name="confirm_password" required minlength="8" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<input type="password" name="confirm_password" required minlength="12" placeholder="Herhaal nieuw wachtwoord" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div class="flex justify-end pt-2">
@@ -861,6 +1268,72 @@ require_once __DIR__ . '/includes/header.php';
</form>
</div>
</div>
<!-- Actieve Apparaten & Sessies Card (SOC 2 CC6.1 / ISO 27001 A.9.4.2) -->
<div class="fociz-card p-6 sm:p-7 space-y-5">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs flex-shrink-0">
<i data-lucide="laptop" class="w-6 h-6"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Actieve Apparaten & Sessies</h3>
<span class="px-2 py-0.5 rounded-full bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/60 text-emerald-700 dark:text-emerald-300 font-semibold text-[10px]">
<?= count($activeSessions) ?> <?= count($activeSessions) === 1 ? 'actieve sessie' : 'actieve sessies' ?>
</span>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Centraal overzicht van alle actieve inlogapparaten conform ISO 27001 en SOC 2 richtlijnen (maximaal 20 actieve sessies).
</p>
</div>
</div>
<?php if (count($activeSessions) > 1): ?>
<form method="POST" action="/profile.php" onsubmit="return confirm('Weet u zeker dat u alle andere actieve sessies op afstand wilt beëindigen? U blijft op dit apparaat ingelogd.')">
<input type="hidden" name="action" value="revoke_other_sessions">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<button type="submit" class="inline-flex items-center space-x-2 px-3.5 py-2 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-950/30 dark:hover:bg-rose-900/40 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/60 text-xs font-semibold transition cursor-pointer">
<i data-lucide="log-out" class="w-3.5 h-3.5"></i>
<span>Overal Uitloggen (Overige Sessies)</span>
</button>
</form>
<?php endif; ?>
</div>
<div class="space-y-2.5">
<?php foreach ($activeSessions as $s): ?>
<div class="p-3.5 rounded-2xl <?= $s['is_current'] ? 'bg-indigo-50/40 dark:bg-indigo-950/20 border-indigo-200/80 dark:border-indigo-800/50' : 'bg-slate-50/70 dark:bg-[#0c0f17]/60 border-slate-200 dark:border-slate-800' ?> border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="flex items-center space-x-3">
<div class="w-9 h-9 rounded-xl <?= $s['is_current'] ? 'bg-indigo-600 text-white shadow-xs' : 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400' ?> flex items-center justify-center flex-shrink-0">
<i data-lucide="<?= stripos($s['device_name'] ?? '', 'Phone') !== false || stripos($s['device_name'] ?? '', 'iPhone') !== false || stripos($s['device_name'] ?? '', 'Android') !== false ? 'smartphone' : 'monitor' ?>" class="w-4 h-4"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<span class="text-xs font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($s['device_name'] ?: 'Onbekend apparaat') ?></span>
<?php if ($s['is_current']): ?>
<span class="px-2 py-0.5 rounded-md bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800/60 text-[10px] font-bold">
Dit apparaat (Huidig)
</span>
<?php endif; ?>
</div>
<div class="text-[11px] text-slate-500 dark:text-slate-400 flex items-center space-x-2 mt-0.5">
<span class="font-mono"><?= htmlspecialchars($s['ip_address']) ?></span>
<span>•</span>
<span>Laatst actief: <?= date('d-m-Y H:i', strtotime($s['last_activity_at'])) ?></span>
</div>
</div>
</div>
<?php if (!$s['is_current']): ?>
<button type="button" onclick="revokeSessionById(<?= (int)$s['id'] ?>)" class="self-end sm:self-center px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-rose-50 dark:bg-slate-800 dark:hover:bg-rose-950/30 text-slate-600 hover:text-rose-600 dark:text-slate-400 dark:hover:text-rose-400 text-xs font-semibold transition border border-slate-200 dark:border-slate-700 hover:border-rose-200 dark:hover:border-rose-800 cursor-pointer">
Sessie Beëindigen
</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<script>
@@ -899,20 +1372,82 @@ function updateProfileThemeUI(theme) {
function toggleJiraToken() {
const input = document.getElementById('jiraApiTokenInput');
const icon = document.getElementById('eyeJiraIcon');
if (!input || !icon) return;
const btn = document.getElementById('toggleJiraTokenBtn');
if (!input) return;
if (input.type === 'password') {
input.type = 'text';
icon.setAttribute('data-lucide', 'eye-off');
if (btn) btn.innerHTML = '<i data-lucide="eye-off" class="w-4 h-4"></i>';
} else {
input.type = 'password';
icon.setAttribute('data-lucide', 'eye');
if (btn) btn.innerHTML = '<i data-lucide="eye" class="w-4 h-4"></i>';
}
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons();
}
}
function previewUploadAvatar(input) {
if (input.files && input.files[0]) {
const file = input.files[0];
const nameEl = document.getElementById('uploadFileName');
if (nameEl) {
nameEl.textContent = '📄 ' + file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
nameEl.classList.remove('hidden');
}
// Live thumbnail weergave
const reader = new FileReader();
reader.onload = function(e) {
let prevImg = document.getElementById('uploadAvatarLivePreview');
if (!prevImg && nameEl && nameEl.parentNode) {
prevImg = document.createElement('img');
prevImg.id = 'uploadAvatarLivePreview';
prevImg.className = 'w-16 h-16 rounded-full mx-auto my-2 object-cover border-2 border-indigo-500 shadow-sm';
nameEl.parentNode.insertBefore(prevImg, nameEl);
}
if (prevImg) {
prevImg.src = e.target.result;
prevImg.classList.remove('hidden');
}
};
reader.readAsDataURL(file);
}
}
function filterAvatarCategory(category) {
const items = document.querySelectorAll('.avatar-item');
items.forEach(el => {
const cat = el.getAttribute('data-category');
if (category === 'all' || cat === category) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
const btns = document.querySelectorAll('.av-cat-btn');
btns.forEach(btn => {
btn.className = 'av-cat-btn px-2.5 py-1 rounded-lg text-[11px] font-semibold transition cursor-pointer bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300';
});
const activeBtn = document.getElementById('avCatBtn-' + category);
if (activeBtn) {
activeBtn.className = 'av-cat-btn px-2.5 py-1 rounded-lg text-[11px] font-bold transition cursor-pointer bg-indigo-600 text-white shadow-xs';
}
if (window.lucide) lucide.createIcons();
}
// Sync banner status met browser permissie & activeer actuele thema kaart
document.addEventListener('DOMContentLoaded', () => {
// Model en provider sync
const profileModelSelect = document.getElementById('profile_ai_model');
const profileProviderInput = document.getElementById('profile_ai_provider');
if (profileModelSelect && profileProviderInput) {
profileModelSelect.addEventListener('change', function() {
if (this.value.indexOf('gemini') !== -1) {
profileProviderInput.value = 'gemini';
} else {
profileProviderInput.value = 'openai';
}
});
}
// Initialiseer geselecteerde thema-kaart
const activeTheme = localStorage.getItem('fociz_theme') || '<?= $userTheme ?>' || 'dark';
updateProfileThemeUI(activeTheme);
@@ -937,6 +1472,79 @@ document.addEventListener('DOMContentLoaded', () => {
if (deniedEl) deniedEl.classList.add('hidden');
}
});
async function testPersonalAiConnection() {
const btn = document.getElementById('profileAiTestBtn');
const btnText = document.getElementById('profileAiTestBtnText');
const resultDiv = document.getElementById('profileAiTestResult');
const apiKey = document.getElementById('profile_ai_api_key').value.trim();
const model = document.getElementById('profile_ai_model').value;
const provider = document.getElementById('profile_ai_provider').value;
resultDiv.classList.add('hidden');
resultDiv.className = 'hidden p-3 rounded-xl text-xs';
btn.disabled = true;
btnText.innerText = 'Testen...';
try {
const payload = {
provider: provider,
model: model,
};
// Stuur alleen de ingevulde sleutel mee als het geen placeholder bolletjes zijn
if (apiKey && apiKey !== '••••••••••••••••') {
payload.api_key = apiKey;
}
const res = await fetch('/api/ai.php?action=test_connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
resultDiv.classList.remove('hidden');
if (data.success) {
resultDiv.className = 'p-3 rounded-xl text-xs bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200 flex items-center space-x-2';
resultDiv.innerHTML = `<i data-lucide="check-circle" class="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0"></i><span><strong>Verbinding Geslaagd!</strong> ${data.message}</span>`;
} else {
resultDiv.className = 'p-3 rounded-xl text-xs bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800 text-rose-800 dark:text-rose-200 flex items-center space-x-2';
resultDiv.innerHTML = `<i data-lucide="alert-circle" class="w-4 h-4 text-rose-600 dark:text-rose-400 flex-shrink-0"></i><span><strong>Verbinding Mislukt:</strong> ${data.error || 'Onbekende fout'}</span>`;
}
if (window.lucide) lucide.createIcons();
} catch (err) {
resultDiv.classList.remove('hidden');
resultDiv.className = 'p-3 rounded-xl text-xs bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800 text-rose-800 dark:text-rose-200 flex items-center space-x-2';
resultDiv.innerHTML = `<i data-lucide="alert-circle" class="w-4 h-4 text-rose-600 dark:text-rose-400 flex-shrink-0"></i><span><strong>Netwerkfout:</strong> ${err.message}</span>`;
if (window.lucide) lucide.createIcons();
} finally {
btn.disabled = false;
btnText.innerText = 'Test Verbinding';
}
}
async function revokeSessionById(sessionId) {
if (!confirm('Weet u zeker dat u deze sessie op afstand wilt beëindigen?')) return;
try {
const formData = new FormData();
formData.append('action', 'revoke_session');
formData.append('session_id', sessionId);
formData.append('csrf_token', '<?= htmlspecialchars(get_csrf_token()) ?>');
const res = await fetch('/api/session.php', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success) {
window.location.reload();
} else {
alert(data.error || 'Fout bij beëindigen van sessie.');
}
} catch (e) {
alert('Netwerkfout bij beëindigen van sessie: ' + e.message);
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+638 -4
View File
@@ -38,6 +38,21 @@ try {
}
$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">
@@ -64,13 +79,21 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<!-- Filters: Quarters & Status -->
<div class="fociz-card p-4 flex flex-wrap items-center justify-between gap-3">
<!-- Quarter Tabs -->
<!-- Quarter Tabs (tevens drop targets) -->
<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' ?>">
Alle Quarters
</a>
<?php foreach ($quarters as $q): ?>
<a href="/roadmap.php?quarter=<?= $q ?>&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-lg transition 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' ?>">
<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"
>
<?= $q ?>
</a>
<?php endforeach; ?>
@@ -108,7 +131,23 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
default => 'border-l-4 border-l-slate-300 dark:border-l-slate-700',
};
?>
<div class="fociz-card-interactive p-5 space-y-3.5 <?= $statusBorder ?>" id="roadmap-item-<?= $item['id'] ?>">
<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"
>
<!-- Top header van item -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
@@ -206,7 +245,7 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
</div>
<!-- Modal: Nieuw Roadmap Item Handmatig Toevoegen -->
<div id="newItemModal" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<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">
@@ -280,6 +319,237 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
</button>
</div>
</div>
</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>
@@ -334,6 +604,370 @@ 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'; ?>
+1359
View File
File diff suppressed because it is too large Load Diff
+102 -20
View File
@@ -4,6 +4,7 @@
*/
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/mailer.php';
$adminUser = require_admin();
$currentUser = $adminUser;
$db = get_db_connection();
@@ -12,8 +13,14 @@ $message = null;
$generatedLink = null;
$error = null;
// Uitnodiging aanmaken
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_invite') {
// 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') {
$email = strtolower(trim($_POST['email'] ?? ''));
$role = in_array($_POST['role'] ?? '', ['ADMIN', 'MEMBER']) ? $_POST['role'] : 'MEMBER';
@@ -29,29 +36,60 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
$token = create_invite($adminUser['id'], $email, $role);
$baseUrl = rtrim(APP_URL, '/');
$generatedLink = "{$baseUrl}/auth/invite.php?token={$token}";
$message = "Uitnodigingslink succesvol aangemaakt!";
}
}
}
// Uitnodiging intrekken
if (isset($_GET['delete_invite'])) {
$inviteId = (int)$_GET['delete_invite'];
// 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);
$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;
}
// Gebruiker verwijderen
if (isset($_GET['delete_user'])) {
$delUserId = (int)$_GET['delete_user'];
} 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();
$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
@@ -91,7 +129,7 @@ require_once __DIR__ . '/includes/header.php';
Team & Uitnodigingen
</h1>
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
Fociz is strikt afgesloten: nieuwe collega's kunnen enkel toegang krijgen na het ontvangen van een uitnodigingslink en verplichte 2FA-activatie.
Beheer collega's, rollen en uitnodigingslinks voor toegang tot de Fociz omgeving.
</p>
</div>
@@ -130,6 +168,7 @@ require_once __DIR__ . '/includes/header.php';
<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>
@@ -177,8 +216,24 @@ require_once __DIR__ . '/includes/header.php';
<?php foreach ($users as $u): ?>
<tr class="hover:bg-slate-50 dark: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>
</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' ?>">
@@ -196,9 +251,14 @@ require_once __DIR__ . '/includes/header.php';
</td>
<td class="p-3 text-right">
<?php if ($u['id'] !== $adminUser['id']): ?>
<a href="/users.php?delete_user=<?= $u['id'] ?>" onclick="return confirm('Gebruiker verwijderen?')" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
<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">
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
</a>
</button>
</form>
<?php else: ?>
<span class="text-[10px] text-slate-400 dark:text-slate-600 font-medium">Uzelf</span>
<?php endif; ?>
@@ -240,7 +300,11 @@ require_once __DIR__ . '/includes/header.php';
?>
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
<td class="p-3 font-mono text-slate-900 dark:text-white"><?= htmlspecialchars($inv['email']) ?></td>
<td class="p-3"><?= htmlspecialchars($inv['role']) ?></td>
<td class="p-3">
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $inv['role'] === 'ADMIN' ? 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300' ?>">
<?= $inv['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
</span>
</td>
<td class="p-3">
<?php if ($isUsed): ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-teal-50 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60">Geaccepteerd</span>
@@ -252,9 +316,27 @@ require_once __DIR__ . '/includes/header.php';
</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-right">
<a href="/users.php?delete_invite=<?= $inv['id'] ?>" onclick="return confirm('Uitnodiging intrekken?')" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
<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">
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
</a>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>