221 Commits

Author SHA1 Message Date
NiekRabelink c55a94618f Update composer.json 2026-10-21 16:19:39 +00:00
NiekRabelink 3b51d2da96 Update composer.json 2026-10-21 16:19:04 +00:00
NiekRabelink dd7295812f Update composer.json 2026-10-21 16:12:13 +00:00
NiekRabelink 5ea25758bd Update composer.json 2026-10-21 16:03:59 +00:00
NiekRabelink ef189273b5 Merge pull request 'Dev' (#101) from dev into main
Reviewed-on: #101
2026-10-21 15:07: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 999f93cc1a Merge pull request 'Rename block' (#100) from dev into main
Reviewed-on: #100
2026-10-05 09:01:36 +00:00
NiekRabelink 07988b3e1d Rename block 2026-10-05 11:01:25 +02:00
NiekRabelink 1d8560f50c Merge branch 'dev' into main: fix live environment indicator and DB 2026-10-05 10:39:29 +02:00
NiekRabelink 0c466972c2 fix(env): lock production hostnames to live environment and live database 2026-10-05 10:39:17 +02:00
NiekRabelink fb9034034a Merge pull request 'Dev' (#99) from dev into main
Reviewed-on: #99
2026-10-05 08:33:53 +00: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
NiekRabelink 5bdaa5fa6e Fix HTTP 500 on live after login, unify database config in config.php, sanitize env example, and harden pentest security 2026-09-09 17:52:11 +02:00
NiekRabelink 919dbe4aed Update config/config.php 2026-09-09 14:51:33 +00:00
NiekRabelink dc28322e98 Update config/.env.example 2026-09-09 14:50:54 +00:00
NiekRabelink 1b6c8e4afd Implement session expiration enforcement: clear remember cookies and redirect to login on session expiry; update header script for daily re-login requirement. 2026-09-09 16:49:29 +02:00
NiekRabelink 1487b680f1 Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-09-09 16:46:41 +02:00
NiekRabelink c5629dcfaa Refactor UI components to use 'fociz-card' and 'fociz-card-interactive' classes for consistent styling across profile, roadmap, and user management pages; update text styles for improved readability and visual hierarchy. 2026-09-09 16:46:18 +02:00
NiekRabelink 4a566182ba Update .gitattributes 2026-09-09 14:40:22 +00:00
NiekRabelink 64a7a3bcf0 Refactor code structure for improved readability and maintainability 2026-09-09 16:30:11 +02:00
NiekRabelink 4cb94d3897 Resolve merge conflicts in multiple files and clean up integration logic 2026-09-09 16:22:59 +02:00
NiekRabelink 8f191f5123 Refactor planner.php to remove commented-out code and clean up integration logic 2026-09-09 16:22:46 +02:00
NiekRabelink 890cbe7446 Merge branch 'main' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-09-09 16:04:44 +02:00
NiekRabelink e879e8c134 Update .gitignore to include config files 2026-09-09 16:00:25 +02:00
NiekRabelink ca6ed7d175 Add environment configuration files for database and application settings 2026-09-09 16:00:05 +02:00
NiekRabelink 9262b09c4d Add roadmap management, service worker, and user invitation features
- Implemented roadmap.php for managing roadmap items with filtering options by quarter and status.
- Added a service worker (sw.js) to handle push notifications and click events for calendar appointments.
- Created users.php for admin to manage team members and invitations, including user creation and deletion functionalities.
2026-09-09 15:54:24 +02:00
NiekRabelink dd738e670b Merge pull request 'Refactor UI styles in roadmap and users pages for improved consistency and aesthetics; updated color schemes, button styles, and layout adjustments.' (#17) from dev into main
Reviewed-on: #17
2026-09-09 13:13:21 +00:00
NiekRabelink 1df22d97f9 Refactor UI styles in roadmap and users pages for improved consistency and aesthetics; updated color schemes, button styles, and layout adjustments. 2026-09-09 15:03:55 +02:00
NiekRabelink 5f6a6e37a0 Update config/config.php 2026-09-09 12:42:31 +00:00
NiekRabelink 59eba99eac Update config/.env.example 2026-09-09 12:41:44 +00:00
NiekRabelink d2f207ffcc Add environment configuration files for database and application settings 2026-09-09 14:39:19 +02:00
NiekRabelink a5324548ec Schone start na index corruptie 2026-09-09 14:31:23 +02:00
99 changed files with 37557 additions and 2034 deletions
+15 -3
View File
@@ -1,4 +1,16 @@
# Zorg dat omgevingsspecifieke bestanden nooit worden overschreven bij merges tussen dev en main
config/config.php merge=ours
includes/env_badge.php merge=ours
# 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
+38
View File
@@ -0,0 +1,38 @@
# =========================================================================
# Fociz Pentest & Security Hardening (.htaccess)
# =========================================================================
# 1. Schakel directory listing uit
Options -Indexes
# 2. Blokkeer directe toegang tot verborgen systeembestanden (.env, .git, etc.)
<FilesMatch "^\.">
<IfModule authz_core_module>
Require all denied
</IfModule>
<IfModule !authz_core_module>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# 3. Blokkeer directe toegang tot gevoelige bestandsformaten
<FilesMatch "\.(sql|log|example|md|json|lock|bak|conf)$">
<IfModule authz_core_module>
Require all denied
</IfModule>
<IfModule !authz_core_module>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# 4. Beveiligingsheaders
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
Header unset X-Powered-By
</IfModule>
+14 -8
View File
@@ -56,13 +56,19 @@ In het bestand **`includes/env_badge.php`**:
- Voortgangsbalken (0 - 100%) en deadlines.
- Gekoppeld aan de oorspronkelijke brainstormkaarten en optioneel Jira Issue Keys (bijv. `FOC-101`).
4. **Integraties Hub (`/integrations.php`)**:
- **Microsoft Outlook**: Synchroniseert agenda-afspraken direct naar de dagplanning.
- **Microsoft Teams**: Directe deelnamelinks naar online meetings en kanaalnotificaties.
- **Atlassian Jira**: Issues synchroniseren en direct met één klik inplannen als dagtaak.
- **Sandbox / Demo Modus**: Schakel met één vinkje tussen live productie en interactieve testdata.
4. **Mijn Profiel & Persoonlijke Koppelingen (`/profile.php`)**:
- **Microsoft Outlook & Teams**: Koppel met 1 klik uw persoonlijke werkagenda voor dagelijkse afsprakensynchronisatie en directe Teams meeting links.
- **Atlassian Jira**: Koppel uw persoonlijke Atlassian API token om aan u toegewezen tickets in te zien en in te plannen.
- **Browser Push Meldingen**: Configureer persoonlijke waarschuwingen (vooraankondigingstijd, herhalingsfrequentie, audio chime en live test).
- **Account & Beveiliging**: Wachtwoord wijzigen en 2FA beveiligingsstatus.
5. **Team & Invites Beheer (`/users.php` - Admin only)**:
5. **Systeembeheer & Integraties Portaal (`/admin.php` - Admin only)**:
- Centrale Microsoft 365 Azure App Registratie (Client ID, Client Secret, Tenant ID, Admin Consent verlenen).
- Standaard Jira bedrijfsdomein voor alle teamleden.
- Systeem & Team Monitor (statistieken over actieve leden, agenda koppelingen en API-status).
- Extensibiliteit Hub voor toekomstige modules (AI Co-Pilot, werkdag standaarden).
6. **Team & Invites Beheer (`/users.php` - Admin only)**:
- Genereer beveiligde uitnodigingslinks voor collega's met kopieerknop.
- Overzicht van actieve gebruikers, rollen en 2FA-status.
@@ -81,7 +87,7 @@ DB_PORT="3306"
DB_NAME="fociz"
DB_USER="uw_mariadb_gebruiker"
DB_PASS="uw_mariadb_wachtwoord"
APP_URL="https://uw-domein.nl"
APP_URL="https://fociz.nl"
```
### 3. Automatische Tabellen Aanmaak
@@ -90,7 +96,7 @@ U hoeft **niet** handmatig SQL-commando's uit te voeren: zodra u de website voor
*(Optioneel kunt u `config/schema.sql` ook zelf importeren via phpMyAdmin of de MariaDB CLI).*
### 4. Eerste Beheerder Instellen
1. Open uw browser en ga naar `https://uw-domein.nl`.
1. Open uw browser en ga naar `https://fociz.nl` (of `https://dev.fociz.nl`).
2. U wordt automatisch doorverwezen naar `/auth/setup.php`.
3. Vul uw naam, e-mailadres en gewenst wachtwoord in.
4. Scan de QR-code met Google Authenticator of Microsoft Authenticator.
+2810
View File
File diff suppressed because it is too large Load Diff
+679
View File
@@ -0,0 +1,679 @@
<?php
/**
* AJAX API voor AI Co-Pilot (Release v1.5)
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/ai.php';
require_once __DIR__ . '/../includes/settings.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
try {
// 1. Auto-categoriseer kaartjes in een brainstormsessie
if ($action === 'categorize_cards') {
$data = json_decode(file_get_contents('php://input'), true);
$sessionId = (int)($data['session_id'] ?? 0);
if (!$sessionId) {
echo json_encode(['error' => 'Sessie ID ontbreekt']);
exit;
}
$stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?");
$stmtSession->execute([$sessionId]);
$session = $stmtSession->fetch();
if (!$session) {
echo json_encode(['error' => 'Sessie niet gevonden']);
exit;
}
$stmtCards = $db->prepare("SELECT id, content, category FROM brainstorm_cards WHERE session_id = ?");
$stmtCards->execute([$sessionId]);
$cards = $stmtCards->fetchAll();
if (empty($cards)) {
echo json_encode(['error' => 'Er zijn nog geen kaartjes op het canvas om te categoriseren']);
exit;
}
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards, $user['id']);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
$validCategories = ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins'];
$updateStmt = $db->prepare("UPDATE brainstorm_cards SET category = ? WHERE id = ? AND session_id = ?");
$updatedCount = 0;
foreach ($aiRes['categorized'] as $item) {
$cardId = (int)($item['id'] ?? 0);
$newCat = trim($item['category'] ?? '');
if ($cardId > 0 && in_array($newCat, $validCategories)) {
$updateStmt->execute([$newCat, $cardId, $sessionId]);
$updatedCount++;
}
}
echo json_encode(['success' => true, 'updated' => $updatedCount]);
exit;
}
// 2. Automatisch ideeën genereren voor een sessie
if ($action === 'generate_ideas') {
$data = json_decode(file_get_contents('php://input'), true);
$sessionId = (int)($data['session_id'] ?? 0);
$count = min(6, max(2, (int)($data['count'] ?? 4)));
$fullBoard = !empty($data['full_board']); // Genereren over alle 4 categorieën o.b.v. briefing
if (!$sessionId) {
echo json_encode(['error' => 'Sessie ID ontbreekt']);
exit;
}
$stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?");
$stmtSession->execute([$sessionId]);
$session = $stmtSession->fetch();
if (!$session) {
echo json_encode(['error' => 'Sessie niet gevonden']);
exit;
}
$authorName = 'AI Co-Pilot';
$createdCards = [];
$colorMap = [
'Ideeën' => 'amber',
'Knelpunten' => 'rose',
'Oplossingen' => 'teal',
'Quick Wins' => 'indigo',
];
if ($fullBoard || !empty($session['description'])) {
// Gebruik de volledige board generator die alle 4 categorieën vult aan de hand van de briefing
$aiRes = fociz_ai_generate_session_board(
$session['title'],
$session['theme'] ?? 'Algemeen',
$session['description'] ?? '',
$user['id']
);
if ($aiRes['success'] && !empty($aiRes['cards'])) {
$insertStmt = $db->prepare("
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
VALUES (?, ?, ?, ?, ?, 1)
");
foreach ($aiRes['cards'] as $cardItem) {
$cText = trim($cardItem['content'] ?? '');
$cCat = trim($cardItem['category'] ?? 'Ideeën');
if (!isset($colorMap[$cCat])) {
$cCat = 'Ideeën';
}
$cColor = $colorMap[$cCat];
if (!empty($cText)) {
$insertStmt->execute([$sessionId, $cText, $cCat, $cColor, $authorName]);
$createdCards[] = [
'id' => (int)$db->lastInsertId(),
'content' => $cText,
'category' => $cCat,
'color' => $cColor,
'author_name' => $authorName,
'votes' => 1,
];
}
}
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'full_board']);
exit;
}
}
// Fallback: reguliere generatie
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id'], $session['description'] ?? '');
$ideas = $aiRes['ideas'] ?? [];
if (empty($ideas)) {
$ideas = [
"Gebruikerservaring en flow optimaliseren voor " . $session['title'],
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
"Koppelingen en integraties verkennen voor snellere afhandeling",
"Feedback- en validatielus inrichten met eindgebruikers",
];
}
$insertStmt = $db->prepare("
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
");
foreach ($ideas as $ideaText) {
$ideaText = trim($ideaText);
if (!empty($ideaText)) {
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
$createdCards[] = [
'id' => (int)$db->lastInsertId(),
'content' => $ideaText,
'category' => 'Ideeën',
'color' => 'amber',
'author_name' => $authorName,
'votes' => 1,
];
}
}
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'ideas']);
exit;
}
// 3. Brainstorm notitie omzetten naar Roadmap Item
if ($action === 'summarize_to_roadmap') {
$data = json_decode(file_get_contents('php://input'), true);
$content = trim($data['content'] ?? '');
$sessionTitle = trim($data['session_title'] ?? '');
if (empty($content)) {
echo json_encode(['error' => 'Geen inhoud opgegeven om samen te vatten']);
exit;
}
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle, $user['id']);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
echo json_encode(['success' => true, 'item' => $aiRes['item']]);
exit;
}
// 3b. AI Brainstorm Idee Uitwerken tot concrete behapbare items
if ($action === 'breakdown_idea') {
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$content = trim($data['content'] ?? '');
$sessionId = (int)($data['session_id'] ?? 0);
$sessionTitle = trim($data['session_title'] ?? '');
$theme = trim($data['theme'] ?? '');
if (empty($content)) {
echo json_encode(['error' => 'Geen inhoud opgegeven om uit te werken.']);
exit;
}
if ($sessionId > 0 && (empty($sessionTitle) || empty($theme))) {
$stmtS = $db->prepare("SELECT title, theme FROM brainstorm_sessions WHERE id = ?");
$stmtS->execute([$sessionId]);
$sRow = $stmtS->fetch();
if ($sRow) {
if (empty($sessionTitle)) $sessionTitle = $sRow['title'] ?? '';
if (empty($theme)) $theme = $sRow['theme'] ?? '';
}
}
$aiRes = fociz_ai_breakdown_idea($content, $sessionTitle, $theme, $user['id']);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
echo json_encode([
'success' => true,
'summary' => $aiRes['summary'],
'items' => $aiRes['items'],
'model' => $aiRes['model'] ?? 'Gemini Pro',
]);
exit;
}
// 3c. Opgesplitste AI items exporteren naar Kanban Bord, Tijdlijn of Dagplanner
if ($action === 'save_breakdown_items') {
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$destination = trim($data['destination'] ?? 'kanban'); // 'kanban', 'timeline', 'planner'
$items = $data['items'] ?? [];
if (empty($items) || !is_array($items)) {
echo json_encode(['error' => 'Geen items geselecteerd om op te slaan.']);
exit;
}
$isAdmin = ($user['role'] === 'ADMIN');
$createdCount = 0;
if ($destination === 'kanban' || $destination === 'timeline') {
$boardId = (int)($data['board_id'] ?? 0);
$laneId = (int)($data['lane_id'] ?? 0);
if ($boardId <= 0) {
// Pak het eerste actieve bord van de gebruiker
if ($isAdmin) {
$boardId = (int)$db->query("SELECT id FROM boards WHERE is_archived = 0 ORDER BY id ASC LIMIT 1")->fetchColumn();
} else {
$stmtB = $db->prepare("SELECT b.id FROM boards b JOIN board_members bm ON b.id = bm.board_id WHERE bm.user_id = ? AND b.is_archived = 0 ORDER BY b.id ASC LIMIT 1");
$stmtB->execute([$user['id']]);
$boardId = (int)$stmtB->fetchColumn();
}
}
if ($boardId <= 0) {
echo json_encode(['error' => 'Geen actief Kanban-bord gevonden om taken aan toe te voegen.']);
exit;
}
// Toegangscontrole
if (!$isAdmin) {
$stmtCheck = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
$stmtCheck->execute([$boardId, $user['id']]);
if ((int)$stmtCheck->fetchColumn() === 0) {
http_response_code(403);
echo json_encode(['error' => 'Geen toegang tot dit bord.']);
exit;
}
}
// Als lane_id niet geldig is, pak de eerste kolom (bijv. 'To Do' of 'Backlog')
if ($laneId <= 0) {
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
$stmtLane->execute([$boardId]);
$laneId = (int)$stmtLane->fetchColumn();
}
if ($laneId <= 0) {
echo json_encode(['error' => 'Het geselecteerde bord heeft geen kolommen.']);
exit;
}
// Datumbereik voor Tijdlijn
$startDate = !empty($data['start_date']) ? trim($data['start_date']) : date('Y-m-d');
$endDate = !empty($data['end_date']) ? trim($data['end_date']) : date('Y-m-d', strtotime('+3 days'));
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
$stmtInsertCard = $db->prepare("
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmtSchedule = $db->prepare("
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
VALUES (?, 'DAILY_RANGE', ?, ?, '09:00', '17:00', '1,2,3,4,5')
");
$priorityMap = ['P1' => 'HIGH', 'P2' => 'MEDIUM', 'P3' => 'LOW'];
foreach ($items as $item) {
$title = trim($item['title'] ?? '');
if (empty($title)) continue;
$desc = trim($item['description'] ?? '');
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
$priority = $priorityMap[$rawPri] ?? ($rawPri === 'HIGH' || $rawPri === 'LOW' ? $rawPri : 'MEDIUM');
$color = trim($item['color'] ?? 'indigo');
$stmtMax->execute([$laneId]);
$maxPos = (int)$stmtMax->fetchColumn();
$stmtInsertCard->execute([
$boardId,
$laneId,
$title,
$desc,
$user['id'],
$priority,
$color,
$maxPos + 1,
$user['id'],
]);
$cardId = (int)$db->lastInsertId();
if ($destination === 'timeline' && $cardId > 0) {
$stmtSchedule->execute([$cardId, $startDate, $endDate]);
}
$createdCount++;
}
echo json_encode([
'success' => true,
'destination' => $destination,
'created_count' => $createdCount,
'board_id' => $boardId,
]);
exit;
}
if ($destination === 'planner') {
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : date('Y-m-d');
// Zoek of maak daily plan
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
$stmtPlan->execute([$user['id'], $planDate]);
$planId = (int)$stmtPlan->fetchColumn();
if ($planId <= 0) {
$stmtNewPlan = $db->prepare("INSERT INTO daily_plans (user_id, plan_date, focus_goal) VALUES (?, ?, 'Brainstorm actiepunten oppakken')");
$stmtNewPlan->execute([$user['id'], $planDate]);
$planId = (int)$db->lastInsertId();
}
$stmtInsertTask = $db->prepare("
INSERT INTO tasks (user_id, daily_plan_id, title, priority, duration_minutes, status, source)
VALUES (?, ?, ?, ?, ?, 'TODO', 'BRAINSTORM')
");
foreach ($items as $item) {
$title = trim($item['title'] ?? '');
if (empty($title)) continue;
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
$priority = in_array($rawPri, ['P1', 'P2', 'P3']) ? $rawPri : 'P2';
$duration = max(15, min(240, (int)($item['estimated_minutes'] ?? 30)));
$stmtInsertTask->execute([
$user['id'],
$planId,
$title,
$priority,
$duration,
]);
$createdCount++;
}
echo json_encode([
'success' => true,
'destination' => 'planner',
'created_count' => $createdCount,
'plan_date' => $planDate,
]);
exit;
}
echo json_encode(['error' => 'Onbekende bestemming: ' . htmlspecialchars($destination)]);
exit;
}
// 3d. Test AI Verbinding (Gemini Pro / OpenAI)
if ($action === 'test_connection') {
try {
$rawInput = file_get_contents('php://input');
$data = !empty($rawInput) ? (json_decode($rawInput, true) ?: []) : $_POST;
$tempProvider = !empty($data['provider']) ? trim((string)$data['provider']) : null;
$tempKey = !empty($data['api_key']) ? trim((string)$data['api_key']) : null;
$tempModel = !empty($data['model']) ? trim((string)$data['model']) : null;
// Als er test credentials zijn meegegeven, test direct met die credentials
if (!empty($tempKey) && $tempKey !== '••••••••••••••••') {
$testSettings = [
'ai_provider' => $tempProvider ?: 'gemini',
'ai_api_key' => $tempKey,
'ai_model' => $tempModel ?: ($tempProvider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro'),
];
} else {
// Anders haal de geconfigureerde instellingen op van user of org
$testSettings = get_ai_settings($user['id']);
if (!empty($tempModel)) {
$testSettings['ai_model'] = $tempModel;
}
if (!empty($tempProvider)) {
$testSettings['ai_provider'] = $tempProvider;
}
}
if (empty($testSettings['ai_api_key'])) {
echo json_encode([
'success' => false,
'error' => 'Geen API-sleutel ingevuld of geconfigureerd om te testen.',
]);
exit;
}
$provider = strtolower($testSettings['ai_provider'] ?? 'gemini');
$model = $testSettings['ai_model'] ?? ($provider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro');
$t0 = microtime(true);
$testMessages = [
['role' => 'user', 'content' => 'Fociz connectiviteitstest. Reageer enkel met het woord OK.'],
];
if ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) {
$aiRes = fociz_ai_chat_gemini($testMessages, ['max_tokens' => 500, 'temperature' => 0.1], $testSettings);
} else {
$aiRes = fociz_ai_chat_openai($testMessages, ['max_tokens' => 500, 'temperature' => 0.1], $testSettings);
}
$latency = round((microtime(true) - $t0) * 1000);
if (!$aiRes['success']) {
echo json_encode([
'success' => false,
'error' => $aiRes['error'] ?? 'Verbindingstest mislukt.',
'latency_ms' => $latency,
'provider' => $provider,
'model' => $model,
]);
exit;
}
$providerName = ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) ? 'Google Gemini Pro' : 'OpenAI';
echo json_encode([
'success' => true,
'message' => "Verbinding met {$providerName} is succesvol tot stand gebracht! (Response tijd: {$latency}ms)",
'latency_ms' => $latency,
'provider' => $provider,
'model' => $aiRes['model'] ?? $model,
'zero_leak' => true,
]);
exit;
} catch (Throwable $testEx) {
echo json_encode([
'success' => false,
'error' => 'Fout tijdens verbindingstest: ' . $testEx->getMessage(),
]);
exit;
}
}
// 4. Slimme dagindeling berekenen voor taken rondom bezette tijdblokken
// ZERO-DATA LEAKAGE: Geen meetingsdetails of externe tickets worden verzonden!
if ($action === 'smart_schedule') {
$data = json_decode(file_get_contents('php://input'), true);
$date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d');
// Haal daily plan op
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
$stmtPlan->execute([$user['id'], $date]);
$plan = $stmtPlan->fetch();
$planId = $plan['id'] ?? 0;
// ZERO-DATA LEAKAGE: Sluit expliciet alle taken uit die afkomstig zijn van Jira, Mail of Teams!
$isToday = ($date === date('Y-m-d'));
$stmtTasks = $db->prepare("
SELECT t.id, t.title, t.priority, t.duration_minutes
FROM tasks t
WHERE t.user_id = ?
AND (t.daily_plan_id = ? " . ($isToday ? "OR t.daily_plan_id IS NULL" : "") . ")
AND t.is_completed = 0
AND (t.source IS NULL OR t.source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
AND t.external_id IS NULL
ORDER BY t.priority ASC, t.created_at ASC
");
$stmtTasks->execute([$user['id'], $planId]);
$tasks = $stmtTasks->fetchAll();
if (empty($tasks)) {
echo json_encode(['error' => 'Geen openstaande Fociz taken gevonden om in te delen']);
exit;
}
// ZERO-DATA LEAKAGE: Haal meetings op maar geef UITSLUITEND start/eindtijd door (geen titel/deelnemers)
$busyBlocks = [];
try {
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$rawEvents = microsoft_fetch_today_events($user['id'], $date);
foreach ($rawEvents as $ev) {
if (!empty($ev['start']) && !empty($ev['end'])) {
$busyBlocks[] = [
'start' => substr($ev['start'], 0, 5),
'end' => substr($ev['end'], 0, 5),
];
}
}
} catch (Throwable $e) {
// Microsoft niet gekoppeld
}
$workdaySettings = get_workday_settings();
$aiRes = fociz_ai_smart_schedule($tasks, $busyBlocks, $workdaySettings, $user['id']);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
// Werk de tijdsblokken bij in MariaDB
$updateStmt = $db->prepare("
UPDATE tasks
SET time_block_start = ?,
duration_minutes = COALESCE(?, duration_minutes)
WHERE id = ? AND user_id = ?
");
$scheduledCount = 0;
foreach ($aiRes['scheduled'] as $s) {
$taskId = (int)($s['id'] ?? 0);
$startTime = trim($s['time_block_start'] ?? '');
$duration = !empty($s['duration_minutes']) ? (int)$s['duration_minutes'] : null;
if ($taskId > 0 && preg_match('/^\d{1,2}:\d{2}$/', $startTime)) {
$updateStmt->execute([$startTime, $duration, $taskId, $user['id']]);
$scheduledCount++;
}
}
echo json_encode(['success' => true, 'scheduled_count' => $scheduledCount]);
exit;
}
// 5. Persoonlijke AI Dagbriefing genereren
// ZERO-DATA LEAKAGE: Absoluut GEEN Jira, Mail of Teams data!
if ($action === 'daily_briefing') {
$data = json_decode(file_get_contents('php://input'), true);
$date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d');
$today = date('Y-m-d');
// Haal openstaande taken van vandaag op (ZERO-DATA LEAKAGE: GEEN externe Jira/Mail taken)
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
$stmtPlan->execute([$user['id'], $date]);
$planId = $stmtPlan->fetchColumn();
$isToday = ($date === $today);
$taskFilter = $planId
? "t.daily_plan_id = " . (int)$planId . ($isToday ? " OR t.daily_plan_id IS NULL" : "")
: ($isToday ? "t.daily_plan_id IS NULL" : "1=0");
$stmtTasks = $db->prepare("
SELECT id, title, priority, time_block_start, duration_minutes
FROM tasks t
WHERE t.user_id = ?
AND t.is_completed = 0
AND ({$taskFilter})
AND (t.source IS NULL OR t.source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
AND t.external_id IS NULL
ORDER BY t.priority ASC, t.time_block_start ASC
LIMIT 15
");
$stmtTasks->execute([$user['id']]);
$tasks = $stmtTasks->fetchAll();
// Haal gisteren niet-voltooide interne taken op
$yesterday = date('Y-m-d', strtotime('-1 day', strtotime($date)));
$stmtYest = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
$stmtYest->execute([$user['id'], $yesterday]);
$yesterdayPlanId = $stmtYest->fetchColumn();
$unfinishedYest = [];
if ($yesterdayPlanId) {
$stmtUnf = $db->prepare("
SELECT title FROM tasks
WHERE user_id = ? AND daily_plan_id = ? AND is_completed = 0
AND (source IS NULL OR source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
AND external_id IS NULL
LIMIT 5
");
$stmtUnf->execute([$user['id'], $yesterdayPlanId]);
$unfinishedYest = $stmtUnf->fetchAll();
}
// ZERO-DATA LEAKAGE: Alleen geanonimiseerde bezette tijdblokken (geen subject/afzender)
$busyBlocks = [];
try {
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$rawEvents = microsoft_fetch_today_events($user['id'], $date);
foreach ($rawEvents as $ev) {
if (!empty($ev['start']) && !empty($ev['end'])) {
$busyBlocks[] = [
'start' => substr($ev['start'], 0, 5),
'end' => substr($ev['end'], 0, 5),
];
}
}
} catch (Throwable $e) {
// Geen agenda beschikbaar
}
$aiSettings = get_ai_settings($user['id']);
if (empty($aiSettings['ai_enabled']) || empty($aiSettings['has_key'])) {
echo json_encode(['error' => 'AI Co-Pilot is niet geconfigureerd']);
exit;
}
$aiRes = fociz_ai_daily_briefing(
$user['name'] ?? 'Jij',
$date,
$tasks,
$busyBlocks,
$unfinishedYest,
$user['id']
);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
echo json_encode([
'success' => true,
'greeting' => $aiRes['greeting'],
'focus_task' => $aiRes['focus_task'],
'tip' => $aiRes['tip'],
'warnings' => $aiRes['warnings'],
'date' => $date,
'model' => $aiRes['model'] ?? 'Gemini Pro',
]);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Throwable $e) {
error_log("api/ai.php Throwable: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Serverfout bij AI verwerking: ' . $e->getMessage()
]);
}
+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()]);
}
+133
View File
@@ -0,0 +1,133 @@
<?php
/**
* AJAX API voor Persoonlijke Agenda Notificaties
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$user = require_auth();
$db = get_db_connection();
// Geef het PHP sessieslot direct vrij zodat paginanavigatie nooit geblokkeerd wordt
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
header('Content-Type: application/json; charset=utf-8');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
$today = date('Y-m-d');
try {
// 1. Instellingen ophalen
if ($action === 'get_settings') {
$settings = get_user_notification_settings((int)$user['id']);
echo json_encode([
'success' => true,
'settings' => $settings,
]);
exit;
}
// 2. Instellingen opslaan
if ($action === 'save_settings') {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$saved = update_user_notification_settings((int)$user['id'], $input);
$settings = get_user_notification_settings((int)$user['id']);
echo json_encode([
'success' => $saved,
'settings' => $settings,
'message' => $saved ? 'Notificatievoorkeuren succesvol opgeslagen.' : 'Fout bij opslaan.',
]);
exit;
}
// 3. Actuele agenda-afspraken van vandaag ophalen voor notificatie-evaluatie
if ($action === 'get_events') {
$userId = (int)$user['id'];
$settings = get_user_notification_settings($userId);
// Controleer of Outlook gekoppeld is
$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
$stmtInt->execute([$userId]);
$outlookInt = $stmtInt->fetch();
$isLiveOutlook = !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode']);
$events = [];
if ($isLiveOutlook) {
$events = microsoft_fetch_today_events($userId, $today);
}
// Als er geen live Outlook events zijn, haal ook taken met tijdblokken op uit Fociz dagplanning
$stmtTasks = $db->prepare("
SELECT t.id, t.title, t.time_block_start, t.time_block_end, t.source, t.external_url
FROM tasks t
LEFT JOIN daily_plans dp ON t.daily_plan_id = dp.id
WHERE t.user_id = ? AND (dp.plan_date = ? OR t.time_block_start IS NOT NULL) AND t.is_completed = 0
ORDER BY t.time_block_start ASC
");
$stmtTasks->execute([$userId, $today]);
$tasks = $stmtTasks->fetchAll();
foreach ($tasks as $t) {
if (!empty($t['time_block_start'])) {
$events[] = [
'id' => 'task_' . $t['id'],
'subject' => $t['title'],
'start' => $t['time_block_start'],
'end' => $t['time_block_end'] ?: date('H:i', strtotime($t['time_block_start'] . ' +30 minutes')),
'start_raw' => "{$today}T{$t['time_block_start']}:00",
'location' => 'Fociz Dagplanning',
'meeting_url' => $t['external_url'] ?? '/planner.php',
'is_all_day' => false,
'source' => 'FOCIZ_TASK',
];
}
}
// Als er nog steeds geen afspraken zijn (en demo mode actief), lever sandbox items zodat er altijd getest kan worden
if (empty($events) && (!empty($outlookInt['is_demo_mode']) || !$isLiveOutlook)) {
$currentHour = (int)date('H');
$nextHour = str_pad((string)(($currentHour + 1) % 24), 2, '0', STR_PAD_LEFT);
$events = [
[
'id' => 'sandbox_meeting_1',
'subject' => 'Focus Sync & Dagstart',
'start' => "{$nextHour}:00",
'end' => "{$nextHour}:30",
'start_raw' => "{$today}T{$nextHour}:00:00",
'location' => 'Microsoft Teams',
'meeting_url' => 'https://teams.microsoft.com',
'is_all_day' => false,
'source' => 'SANDBOX',
]
];
}
echo json_encode([
'success' => true,
'server_time' => date('H:i:s'),
'server_datetime' => date('Y-m-d H:i:s'),
'server_timestamp'=> time(),
'settings' => $settings,
'events' => $events,
'count' => count($events),
]);
exit;
}
echo json_encode(['error' => 'Ongeldige actie']);
exit;
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
]);
exit;
}
+1531 -11
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;
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* Fociz Thema API Endpoint (Dark / Light / System)
*/
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../includes/auth.php';
$user = require_auth(true);
$userId = (int)$user['id'];
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$currentTheme = get_user_theme($userId);
echo json_encode([
'success' => true,
'theme' => $currentTheme,
]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$theme = $data['theme'] ?? ($_POST['theme'] ?? 'dark');
if (!in_array($theme, ['dark', 'light', 'system'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ongeldig thema. Kies dark, light of system.']);
exit;
}
$ok = update_user_theme($userId, $theme);
if ($ok) {
echo json_encode([
'success' => true,
'theme' => $theme,
'message' => 'Themavoorkeur succesvol opgeslagen.',
]);
} else {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Kon thema niet opslaan in database.']);
}
exit;
}
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
+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

+541
View File
@@ -0,0 +1,541 @@
/**
* Fociz Browser Push Notificaties Engine
* Monitort actuele agenda-afspraken en stuurt browser notificaties op basis van persoonlijk profiel.
*/
(function () {
// 1. Globale state
window.focizNotifSettings = {
enabled: 1,
lead_time_minutes: 5,
repeat_count: 2,
repeat_interval_minutes: 2,
sound_enabled: 1
};
let cachedEvents = [];
let isFetchingEvents = false;
// 2. Service Worker Registratie
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(reg => {
// Service worker geregistreerd
})
.catch(err => {
// Stille fallback indien niet op HTTPS of lokaal
});
}
// 3. Web Audio API Chime Synthesizer
window.playNotificationChime = function () {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const ctx = new AudioContext();
const now = ctx.currentTime;
// Noot 1: 587.33 Hz (D5)
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.type = 'sine';
osc1.frequency.setValueAtTime(587.33, now);
gain1.gain.setValueAtTime(0.2, now);
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
osc1.connect(gain1);
gain1.connect(ctx.destination);
osc1.start(now);
osc1.stop(now + 0.35);
// Noot 2: 880 Hz (A5)
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.type = 'sine';
osc2.frequency.setValueAtTime(880.0, now + 0.12);
gain2.gain.setValueAtTime(0.25, now + 0.12);
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.55);
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.start(now + 0.12);
osc2.stop(now + 0.55);
} catch (e) {
console.warn('Audio chime kon niet afspelen:', e);
}
};
// 4. In-App Toast Banner
function showInAppToast(title, body, meetingUrl) {
let container = document.getElementById('focizToastContainer');
if (!container) {
container = document.createElement('div');
container.id = 'focizToastContainer';
container.className = 'fixed bottom-5 right-5 z-[9999] flex flex-col space-y-3 max-w-sm pointer-events-none';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'pointer-events-auto bg-slate-900/95 border border-blue-500/40 text-white p-4 rounded-2xl shadow-2xl backdrop-blur-md flex items-start space-x-3 transform translate-y-4 opacity-0 transition duration-300 ease-out';
const iconDiv = document.createElement('div');
iconDiv.className = 'w-9 h-9 rounded-xl bg-blue-500/20 text-blue-400 flex items-center justify-center flex-shrink-0 mt-0.5 border border-blue-500/30';
iconDiv.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke-width="2"/><line x1="16" y1="2" x2="16" y2="6" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="6" stroke-width="2"/><line x1="3" y1="10" x2="21" y2="10" stroke-width="2"/></svg>';
const contentDiv = document.createElement('div');
contentDiv.className = 'flex-1 min-w-0';
const titleEl = document.createElement('div');
titleEl.className = 'text-xs font-bold text-white truncate';
titleEl.textContent = title;
const bodyEl = document.createElement('div');
bodyEl.className = 'text-[11px] text-slate-300 mt-0.5 leading-snug';
bodyEl.textContent = body;
contentDiv.appendChild(titleEl);
contentDiv.appendChild(bodyEl);
if (meetingUrl) {
const btn = document.createElement('a');
btn.href = meetingUrl;
btn.target = '_blank';
btn.className = 'inline-flex items-center space-x-1 mt-2 px-2.5 py-1 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold transition';
btn.innerHTML = '<span>Direct Deelnemen</span> &rarr;';
contentDiv.appendChild(btn);
}
const closeBtn = document.createElement('button');
closeBtn.className = 'text-slate-500 hover:text-white transition -mr-1 -mt-1 p-1';
closeBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>';
closeBtn.onclick = () => {
toast.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => toast.remove(), 300);
};
toast.appendChild(iconDiv);
toast.appendChild(contentDiv);
toast.appendChild(closeBtn);
container.appendChild(toast);
// Animaties
requestAnimationFrame(() => {
toast.classList.remove('opacity-0', 'translate-y-4');
});
// Automatisch verdwijnen na 10 seconden
setTimeout(() => {
if (toast.parentElement) {
toast.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => toast.remove(), 300);
}
}, 10000);
}
// 5. Browser Notificatie Verzenden
window.sendBrowserNotification = async function (title, body, meetingUrl, tag) {
if (!window.focizNotifSettings.enabled) return;
// Geluidssignaal
if (window.focizNotifSettings.sound_enabled) {
playNotificationChime();
}
// In-app toast banner
showInAppToast(title, body, meetingUrl);
// HTML5 Browser Push Notificatie
if ('Notification' in window && Notification.permission === 'granted') {
try {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
const reg = await navigator.serviceWorker.ready;
if (reg && reg.showNotification) {
reg.showNotification(title, {
body: body,
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: tag || 'fociz-' + Date.now(),
data: { url: meetingUrl || '/planner.php' },
requireInteraction: false
});
return;
}
}
} catch (e) {
// Fallback
}
try {
const n = new Notification(title, {
body: body,
icon: '/favicon.ico',
tag: tag || 'fociz-' + Date.now()
});
n.onclick = function () {
window.focus();
if (meetingUrl) {
window.open(meetingUrl, '_blank');
}
n.close();
};
} catch (err) {
console.warn('Desktop notificatie kon niet worden getoond:', err);
}
}
};
// 6. Evaluatie van agenda-afspraken
function evaluateEvents() {
if (!window.focizNotifSettings.enabled) return;
if (!cachedEvents || cachedEvents.length === 0) return;
const now = Date.now();
const leadMs = (parseInt(window.focizNotifSettings.lead_time_minutes) || 5) * 60 * 1000;
const repeatCount = Math.max(1, parseInt(window.focizNotifSettings.repeat_count) || 2);
const intervalMs = (parseInt(window.focizNotifSettings.repeat_interval_minutes) || 2) * 60 * 1000;
cachedEvents.forEach(evt => {
if (!evt.start_raw && !evt.start) return;
// Bepaal starttijd in milliseconds
let startMs = 0;
if (evt.start_raw) {
startMs = new Date(evt.start_raw).getTime();
} else if (evt.start) {
const parts = evt.start.split(':');
const today = new Date();
today.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), 0, 0);
startMs = today.getTime();
}
if (!startMs || isNaN(startMs)) return;
// Check herhalingen
for (let i = 0; i < repeatCount; i++) {
// Eerste notificatie: start - leadMs
// Volgende notificaties: T_0 + i * intervalMs (tot maximaal starttijd)
let triggerMs = startMs - leadMs + (i * intervalMs);
if (triggerMs > startMs) {
triggerMs = startMs;
}
// Venster: notificatie is nu actueel (tussen triggerMs en 4 minuten na de start)
const isDue = (now >= triggerMs) && (now <= startMs + (4 * 60 * 1000));
if (isDue) {
const storageKey = `fociz_notif_${evt.id}_rep_${i}`;
const alreadySent = localStorage.getItem(storageKey);
if (!alreadySent) {
localStorage.setItem(storageKey, String(now));
const minutesLeft = Math.max(0, Math.round((startMs - now) / 60000));
let notifTitle = '';
let notifBody = '';
if (now >= startMs) {
notifTitle = `🚀 Nu gestart: ${evt.subject}`;
notifBody = `Afspraak is begonnen (${evt.start}) • ${evt.location || 'Teams'}`;
} else if (minutesLeft === 0) {
notifTitle = `⚡ Begint zo: ${evt.subject}`;
notifBody = `Start om ${evt.start} • ${evt.location || 'Teams'}`;
} else {
notifTitle = `📅 Over ${minutesLeft} min: ${evt.subject}`;
notifBody = `Aanvang ${evt.start} - ${evt.end} • ${evt.location || 'Teams'}`;
}
sendBrowserNotification(notifTitle, notifBody, evt.meeting_url, `fociz-event-${evt.id}-${i}`);
}
}
}
});
}
// 7. Data ophalen van backend
async function refreshEvents() {
if (isFetchingEvents) return;
isFetchingEvents = true;
try {
const res = await fetch('/api/notifications.php?action=get_events', {
cache: 'no-store',
headers: { 'X-Background-Poll': '1' }
});
if (res.status === 401) {
console.warn('Notificatiesessie niet geauthenticeerd.');
return;
}
if (res.ok) {
const data = await res.json();
if (data && data.success) {
cachedEvents = data.events || [];
if (data.settings) {
window.focizNotifSettings = data.settings;
updateDotStatus();
}
evaluateEvents();
}
}
} catch (e) {
// Netwerkfout stil opvangen
} finally {
isFetchingEvents = false;
}
}
// 8. Dot indicator bijwerken op de bel in de header
function updateDotStatus() {
const dot = document.getElementById('focizNotificationDot');
if (!dot) return;
if ('Notification' in window && Notification.permission === 'granted' && window.focizNotifSettings.enabled) {
dot.classList.remove('hidden');
} else {
dot.classList.add('hidden');
}
}
// 9. Modal helpers
window.openNotificationModal = function () {
const modal = document.getElementById('focizNotificationModal');
if (!modal) return;
// Vul actuele waarden in formulier
const s = window.focizNotifSettings;
const enabledCheckbox = document.getElementById('notifEnabledInput');
const leadTimeSelect = document.getElementById('notifLeadTimeSelect');
const repeatCountSelect = document.getElementById('notifRepeatCountSelect');
const intervalSelect = document.getElementById('notifIntervalSelect');
const soundCheckbox = document.getElementById('notifSoundInput');
if (enabledCheckbox) enabledCheckbox.checked = !!parseInt(s.enabled);
if (leadTimeSelect) leadTimeSelect.value = String(s.lead_time_minutes);
if (repeatCountSelect) repeatCountSelect.value = String(s.repeat_count);
if (intervalSelect) intervalSelect.value = String(s.repeat_interval_minutes);
if (soundCheckbox) soundCheckbox.checked = !!parseInt(s.sound_enabled);
updatePermissionBanner();
modal.classList.remove('hidden');
modal.classList.add('flex');
};
window.closeNotificationModal = function () {
const modal = document.getElementById('focizNotificationModal');
if (!modal) return;
modal.classList.add('hidden');
modal.classList.remove('flex');
};
function updatePermissionBanner() {
const grantedDiv = document.getElementById('notifPermGranted');
const requestDiv = document.getElementById('notifPermRequest');
const deniedDiv = document.getElementById('notifPermDenied');
if (!('Notification' in window)) {
if (grantedDiv) grantedDiv.classList.add('hidden');
if (requestDiv) requestDiv.classList.add('hidden');
if (deniedDiv) deniedDiv.classList.remove('hidden');
return;
}
const perm = Notification.permission;
if (perm === 'granted') {
if (grantedDiv) grantedDiv.classList.remove('hidden');
if (requestDiv) requestDiv.classList.add('hidden');
if (deniedDiv) deniedDiv.classList.add('hidden');
} else if (perm === 'denied') {
if (grantedDiv) grantedDiv.classList.add('hidden');
if (requestDiv) requestDiv.classList.add('hidden');
if (deniedDiv) deniedDiv.classList.remove('hidden');
} else {
if (grantedDiv) grantedDiv.classList.add('hidden');
if (requestDiv) requestDiv.classList.remove('hidden');
if (deniedDiv) deniedDiv.classList.add('hidden');
}
}
window.focizRequestPermission = async function () {
if (!('Notification' in window)) {
alert('Uw browser ondersteunt helaas geen HTML5 Web Notificaties.');
return;
}
try {
const res = await Notification.requestPermission();
updatePermissionBanner();
updateDotStatus();
if (res === 'granted') {
sendBrowserNotification('✅ Notificaties Ingeschakeld!', 'Fociz stuurt u nu pushberichten bij actuele afspraken.', null, 'fociz-test-perm');
}
} catch (e) {
console.error(e);
}
};
window.focizTestNotification = function () {
sendBrowserNotification('🔔 Test Notificatie Fociz', 'Dit is hoe uw agenda-herinnering eruit ziet. Het geluid en de melding werken perfect!', '/planner.php', 'fociz-test-manual');
};
window.saveNotificationPreferences = async function (e) {
if (e) e.preventDefault();
const btn = document.getElementById('saveNotifBtn');
if (btn) btn.disabled = true;
const payload = {
enabled: document.getElementById('notifEnabledInput').checked ? 1 : 0,
lead_time_minutes: parseInt(document.getElementById('notifLeadTimeSelect').value, 10),
repeat_count: parseInt(document.getElementById('notifRepeatCountSelect').value, 10),
repeat_interval_minutes: parseInt(document.getElementById('notifIntervalSelect').value, 10),
sound_enabled: document.getElementById('notifSoundInput').checked ? 1 : 0
};
try {
const res = await fetch('/api/notifications.php?action=save_settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
const data = await res.json();
if (data && data.success) {
window.focizNotifSettings = data.settings;
updateDotStatus();
const statusMsg = document.getElementById('notifSaveStatus');
if (statusMsg) {
statusMsg.textContent = 'Voorkeuren succesvol opgeslagen!';
statusMsg.classList.remove('hidden');
setTimeout(() => statusMsg.classList.add('hidden'), 3000);
}
setTimeout(() => closeNotificationModal(), 1000);
}
}
} catch (err) {
alert('Fout bij opslaan van voorkeuren: ' + err.message);
} finally {
if (btn) btn.disabled = false;
}
};
// 7b. Real-time Live Sync (Mail, Jira & Agenda)
let lastKnownMailId = localStorage.getItem('fociz_last_mail_id') || '';
let lastKnownJiraKey = localStorage.getItem('fociz_last_jira_key') || '';
let lastCalendarHash = '';
let isLiveSyncRunning = false;
let isInitialLiveSync = true;
async function checkLiveSync() {
if (isLiveSyncRunning) return;
isLiveSyncRunning = true;
try {
const url = `/api/live_sync.php?last_mail_id=${encodeURIComponent(lastKnownMailId)}&last_jira_key=${encodeURIComponent(lastKnownJiraKey)}&calendar_hash=${encodeURIComponent(lastCalendarHash)}`;
const res = await fetch(url, {
cache: 'no-store',
headers: { 'X-Background-Poll': '1' }
});
if (res.status === 401) return;
if (res.ok) {
const data = await res.json();
if (data && data.success) {
// Update mail badge in navigatie (desktop & mobiel)
const mailBadge = document.getElementById('navMailBadge');
const mailBadgeMobile = document.getElementById('navMailBadgeMobile');
if (typeof data.mail?.unread_count !== 'undefined') {
const count = parseInt(data.mail.unread_count, 10) || 0;
const badgeText = count > 99 ? '99+' : count;
[mailBadge, mailBadgeMobile].forEach(el => {
if (!el) return;
if (count > 0) {
el.innerText = badgeText;
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
}
// E-mail melding
if (!isInitialLiveSync && data.mail?.has_new && data.mail.new_mail) {
const m = data.mail.new_mail;
sendBrowserNotification(
`✉️ Nieuwe mail: ${m.from_name}`,
`${m.subject} • ${m.received_human || 'Zojuist'}`,
m.web_link || '/mail.php',
`fociz-mail-${m.id}`
);
}
// Jira ticket melding
if (!isInitialLiveSync && data.jira?.has_new && data.jira.new_issue) {
const j = data.jira.new_issue;
sendBrowserNotification(
`⚡ Nieuw Jira ticket: [${j.key}]`,
`${j.summary} (${j.priority})`,
j.url || '/jira.php',
`fociz-jira-${j.key}`
);
}
// Dispatch custom event voor actieve pagina's
if (data.mail?.has_new || (data.mail?.latest_id && data.mail.latest_id !== lastKnownMailId)) {
window.dispatchEvent(new CustomEvent('fociz:mail-updated', { detail: data.mail }));
}
if (data.jira?.has_new || (data.jira?.newest_key && data.jira.newest_key !== lastKnownJiraKey)) {
window.dispatchEvent(new CustomEvent('fociz:jira-updated', { detail: data.jira }));
}
if (data.calendar?.has_changes) {
window.dispatchEvent(new CustomEvent('fociz:calendar-updated', { detail: data.calendar }));
}
if (data.mail?.latest_id) {
lastKnownMailId = data.mail.latest_id;
localStorage.setItem('fociz_last_mail_id', lastKnownMailId);
}
if (data.jira?.newest_key) {
lastKnownJiraKey = data.jira.newest_key;
localStorage.setItem('fociz_last_jira_key', lastKnownJiraKey);
}
if (data.calendar?.hash) {
lastCalendarHash = data.calendar.hash;
}
isInitialLiveSync = false;
}
}
} catch(e) {
// Stil negeren bij netwerkvertraging
} finally {
isLiveSyncRunning = false;
}
}
// 10. Initialisatie
document.addEventListener('DOMContentLoaded', () => {
refreshEvents();
checkLiveSync();
// Polling interval elke 60 seconden voor agenda evaluatie
setInterval(refreshEvents, 60000);
// Evaluatie interval elke 15 seconden voor agenda popups
setInterval(evaluateEvents, 15000);
// Live sync interval elke 30 seconden voor mail, Jira & calendar updates
setInterval(checkLiveSync, 30000);
// Intelligente throttle voor window focus en tabwisseling (max 1x per 20s)
let lastFocusCheck = Date.now();
const throttledSync = () => {
const now = Date.now();
if (now - lastFocusCheck > 20000) {
lastFocusCheck = now;
checkLiveSync();
refreshEvents();
}
};
window.addEventListener('focus', throttledSync);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
throttledSync();
}
});
updateDotStatus();
});
})();
+1
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

+86 -27
View File
@@ -7,45 +7,83 @@
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$user = require_auth();
start_secure_session();
// 1. CSRF State validatie & Sessieherstel
$receivedState = $_GET['state'] ?? '';
$stateUserId = !empty($receivedState) ? verify_oauth_state($receivedState) : null;
// Als de sessie verloren is gegaan tijdens de externe cross-site redirect vanaf Microsoft,
// herstellen we de gebruiker veilig aan de hand van de cryptografisch ondertekende OAuth state!
if (empty($_SESSION['user_id']) && $stateUserId) {
try {
$db = get_db_connection();
$stmtUser = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?");
$stmtUser->execute([$stateUserId]);
$recoveredUser = $stmtUser->fetch();
if ($recoveredUser) {
$_SESSION['user_id'] = $recoveredUser['id'];
$_SESSION['user_name'] = $recoveredUser['name'];
$_SESSION['user_email'] = $recoveredUser['email'];
$_SESSION['user_role'] = $recoveredUser['role'];
$_SESSION['is_2fa_verified'] = true;
$_SESSION['auth_date'] = date('Y-m-d');
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
clear_remember_cookie();
}
} catch (Throwable $e) {
// Foutafhandeling
}
}
// 2. Controleer authenticatie
$user = get_logged_in_user();
if (!$user) {
header('Location: /auth/login.php?error=' . urlencode('Sessie verlopen tijdens Microsoft koppeling. Log opnieuw in en probeer het nogmaals.'));
exit;
}
$db = get_db_connection();
// 1. Foutafhandeling vanuit Microsoft
// 3. Foutafhandeling vanuit Microsoft (bijv. gebruiker weigerde machtiging)
if (!empty($_GET['error'])) {
$errorDescription = $_GET['error_description'] ?? $_GET['error'];
header('Location: /integrations.php?error=' . urlencode("Microsoft melding: {$errorDescription}"));
header('Location: /profile.php?error=' . urlencode("Microsoft melding: {$errorDescription}"));
exit;
}
// 2. CSRF State validatie
$receivedState = $_GET['state'] ?? '';
// 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']);
unset($_SESSION['ms_oauth_state'], $_SESSION['ms_oauth_user_id']);
if (empty($receivedState) || !hash_equals($expectedState, $receivedState)) {
header('Location: /integrations.php?error=' . urlencode('Ongeldige sessie of CSRF-controle mislukt. Probeer opnieuw in te loggen via Microsoft.'));
if (!$stateUserId && (empty($receivedState) || empty($expectedState) || !hash_equals($expectedState, $receivedState))) {
header('Location: /profile.php?error=' . urlencode('Ongeldige sessie of CSRF-controle mislukt. Probeer opnieuw in te loggen via Microsoft.'));
exit;
}
// 3. Autorisatiecode controleren
// 5. Autorisatiecode controleren
$code = $_GET['code'] ?? '';
if (empty($code)) {
header('Location: /integrations.php?error=' . urlencode('Geen autorisatiecode ontvangen van Microsoft.'));
header('Location: /profile.php?error=' . urlencode('Geen autorisatiecode ontvangen van Microsoft.'));
exit;
}
// 4. Instellingen ophalen
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$user['id']]);
$row = $stmt->fetch();
$savedSettings = json_decode($row['settings'] ?? '{}', true) ?: [];
// 4. Centrale instellingen ophalen
$appConfig = microsoft_get_app_config();
$clientId = !empty($savedSettings['client_id']) ? $savedSettings['client_id'] : fociz_env('MICROSOFT_CLIENT_ID', '');
$clientSecret = !empty($savedSettings['client_secret']) ? $savedSettings['client_secret'] : fociz_env('MICROSOFT_CLIENT_SECRET', '');
$tenantId = !empty($savedSettings['tenant_id']) ? $savedSettings['tenant_id'] : fociz_env('MICROSOFT_TENANT_ID', 'common');
$clientId = $appConfig['client_id'];
$clientSecret = $appConfig['client_secret'];
$tenantId = (!empty($appConfig['tenant_id']) && $appConfig['tenant_id'] !== 'common') ? $appConfig['tenant_id'] : 'planningsagenda.nl';
if (empty($clientId) || empty($clientSecret)) {
header('Location: /integrations.php?error=' . urlencode('Client ID of Client Secret ontbreekt in Fociz. Vul deze eerst in bij de Outlook instellingen.'));
header('Location: /profile.php?error=' . urlencode('Microsoft 365 configuratie (Client ID of Secret) ontbreekt in Fociz. Neem contact op met de beheerder.'));
exit;
}
@@ -55,7 +93,7 @@ $redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
$tokenResult = microsoft_exchange_code($code, $clientId, $clientSecret, $redirectUri, $tenantId);
if (!$tokenResult['success']) {
header('Location: /integrations.php?error=' . urlencode('Token uitwisseling mislukt: ' . $tokenResult['error']));
header('Location: /profile.php?error=' . urlencode('Token uitwisseling mislukt: ' . $tokenResult['error']));
exit;
}
@@ -70,15 +108,24 @@ $accountEmail = $profile['mail'] ?? ($profile['userPrincipalName'] ?? $user['ema
$accountName = $profile['displayName'] ?? 'Microsoft 365 Account';
// 7. Opslaan in MariaDB
$newSettings = array_merge($savedSettings, [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'tenant_id' => $tenantId,
$stmtExisting = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmtExisting->execute([$user['id']]);
$existingRow = $stmtExisting->fetch();
$existingSettings = json_decode($existingRow['settings'] ?? '{}', true) ?: [];
$newSettings = array_merge($existingSettings, [
'expires_at' => $expiresAt,
'account_email' => $accountEmail,
'account_name' => $accountName,
]);
// Als de ingelogde gebruiker Beheerder (ADMIN) is, bewaren we ook de centrale app credentials
if ($user['role'] === 'ADMIN') {
$newSettings['client_id'] = $clientId;
$newSettings['client_secret'] = $clientSecret;
$newSettings['tenant_id'] = $tenantId;
}
$saveStmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, access_token, refresh_token, settings, last_sync_at)
VALUES (?, 'OUTLOOK', 1, 0, ?, ?, ?, NOW())
@@ -90,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 Integraties Hub
header('Location: /integrations.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>
+91 -31
View File
@@ -14,11 +14,11 @@ if (!$invite) {
<!DOCTYPE html>
<html lang='nl' class='dark'>
<head><script src='https://cdn.tailwindcss.com'></script></head>
<body class='bg-[#090d16] text-white min-h-screen flex items-center justify-center p-4'>
<div class='max-w-md bg-slate-900 border border-slate-800 rounded-2xl p-8 text-center'>
<h2 class='text-xl font-bold text-red-400 mb-2'>Ongeldige Uitnodiging</h2>
<body class='bg-[#0c0f17] text-white min-h-screen flex items-center justify-center p-4'>
<div class='max-w-md bg-[#111622] border border-slate-800 rounded-2xl p-8 text-center shadow-xs'>
<h2 class='text-xl font-bold text-rose-400 mb-2'>Ongeldige Uitnodiging</h2>
<p class='text-sm text-slate-400 mb-6'>Deze uitnodigingslink is ongeldig, verlopen of al eerder gebruikt.</p>
<a href='/auth/login.php' class='px-4 py-2 bg-emerald-500 text-slate-950 font-bold rounded-xl text-xs'>Naar Inloggen</a>
<a href='/auth/login.php' class='px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl text-xs transition shadow-xs'>Naar Inloggen</a>
</div>
</body>
</html>
@@ -39,8 +39,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($name) || empty($password) || empty($totpCode)) {
$error = "Alle velden (inclusief 2FA verificatiecode) zijn verplicht.";
} elseif (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,12 +75,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db->commit();
// Start sessie
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');
@@ -102,27 +119,58 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage -->
<script>
(function() {
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
var isDark = true;
if (activeTheme === 'dark') isDark = true;
else if (activeTheme === 'light') isDark = false;
else if (activeTheme === 'system') {
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
if (isDark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: { 850: '#151f32', 950: '#070b12' }
}
}
}
}
</script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
<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-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
<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-lg bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
<div class="w-full max-w-lg bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
<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="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-2xl font-bold text-white">Welkom bij Fociz</h1>
<p class="text-slate-400 text-sm mt-1">
Uitgenodigd door <strong class="text-slate-200"><?= htmlspecialchars($invite['inviter_name']) ?></strong> voor <span class="text-emerald-400 font-mono text-xs bg-emerald-500/10 px-2 py-0.5 rounded"><?= htmlspecialchars($invite['email']) ?></span>
<h1 class="text-2xl font-black text-slate-900 dark:text-white">Welkom bij Fociz</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
Uitgenodigd door <strong class="text-slate-900 dark:text-slate-200"><?= htmlspecialchars($invite['inviter_name']) ?></strong> voor <span class="text-indigo-600 dark:text-indigo-400 font-mono text-xs bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 px-2 py-0.5 rounded-lg"><?= htmlspecialchars($invite['email']) ?></span>
</p>
</div>
<?php if ($error): ?>
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
<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>
@@ -132,55 +180,67 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Uw naam</label>
<input type="text" name="name" required placeholder="Bijv. Sarah de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Uw naam</label>
<input type="text" name="name" required placeholder="Bijv. Sarah de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
<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-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
<input type="password" name="confirm_password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
</div>
<!-- 2FA Verplichting -->
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
<h3 class="text-sm font-bold text-white uppercase tracking-wider text-emerald-400">
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center space-y-4">
<h3 class="text-sm font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
Verplichte Tweestapsverificatie (2FA)
</h3>
<p class="text-xs text-slate-400">
<p class="text-xs text-slate-500 dark:text-slate-400">
Scan deze QR-code in uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
</p>
<div class="inline-block p-3 bg-white rounded-2xl shadow-xl">
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-40 h-40 mx-auto">
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
<div id="qrcode" class="w-40 h-40 mx-auto flex items-center justify-center"></div>
</div>
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono">
Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong>
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono">
Sleutel: <strong class="text-indigo-600 dark:text-indigo-400"><?= htmlspecialchars($secret) ?></strong>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1.5">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Voer de 6-cijferige code uit uw app in:
</label>
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-2 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
</div>
</div>
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<button type="submit" class="w-full mt-6 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<span>Account Activeren & Inloggen</span>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
</button>
</form>
</div>
<script src="/assets/js/qrcode.min.js"></script>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
const qrContainer = document.getElementById('qrcode');
if (qrContainer) {
new QRCode(qrContainer, {
text: <?= json_encode($otpAuthUrl) ?>,
width: 160,
height: 160,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
}
</script>
</body>
</html>
+264 -45
View File
@@ -12,32 +12,98 @@ if (!has_admin_user()) {
exit;
}
// Als al volledig ingelogd en 2FA geverifieerd, ga naar planner
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
// 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) {
header('Location: /planner.php');
exit;
}
$error = null;
$infoMessage = null;
if (isset($_GET['expired'])) {
$infoMessage = "Uw sessie is beeïndigd";
}
if (isset($_GET['revoked'])) {
$infoMessage = "Uw sessie is op afstand beëindigd of ingetrokken. Log opnieuw in om verder te gaan.";
}
if (isset($_GET['ip_changed'])) {
$infoMessage = "U bent ingelogd vanaf een andere netwerklocatie. Uit veiligheidsoverwegingen dient u opnieuw in te loggen met 2FA.";
}
if (isset($_GET['ip_verified'])) {
$infoMessage = "Nieuwe inloglocatie succesvol geverifieerd! U kunt nu veilig inloggen.";
}
$step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
// Terug naar stap 1
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email']);
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);
// STAP 1: Wachtwoord verificatie
if ($step === 1) {
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
$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 = ?");
@@ -45,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
$_SESSION['pending_2fa_user_id'] = $user['id'];
$_SESSION['pending_2fa_user_email'] = $user['email'];
$_SESSION['pending_2fa_user_name'] = $user['name'];
$step = 2;
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.";
}
}
@@ -58,23 +149,38 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// STAP 2: 2FA TOTP Code verificatie
elseif ($step === 2) {
$userId = $_SESSION['pending_2fa_user_id'] ?? null;
$totpCode = trim(str_replace(' ', '', $_POST['totp_code'] ?? ''));
$totpCode = trim($_POST['totp_code'] ?? '');
if (!$userId || empty($totpCode)) {
$error = "Vul de 6-cijferige 2FA verificatiecode in.";
if (!$userId) {
header('Location: /auth/login.php');
exit;
}
$totpRateKey = "login_totp_{$userId}";
$totpLimit = fociz_check_rate_limit($totpRateKey, 5, 900);
if (!$totpLimit['allowed']) {
$error = "Te veel ongeldige verificatiecodes. Probeer het over " . ceil($totpLimit['retry_after'] / 60) . " minuten opnieuw.";
} elseif (empty($totpCode)) {
$error = "Vul de 6-cijferige 2FA code in uit uw authenticator app.";
} else {
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_secret, backup_codes FROM users WHERE id = ?");
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$userId]);
$user = $stmt->fetch();
$isValid = false;
// 1. Controleer TOTP code
if (!empty($user['two_factor_secret']) && TOTP::verifyCode($user['two_factor_secret'], $totpCode)) {
$isValid = true;
// Controleer reguliere TOTP code
if (!empty($user['two_factor_secret'])) {
try {
$isValid = TOTP::verifyCode($user['two_factor_secret'], $totpCode);
} catch (Throwable $t) {
error_log("TOTP verificatiefout: " . $t->getMessage());
$isValid = false;
}
}
// 2. Controleer optioneel backup codes
// Fallback: controleer 8-karakter backup codes
if (!$isValid && !empty($user['backup_codes'])) {
$backupCodes = json_decode($user['backup_codes'], true) ?: [];
$codeUpper = strtoupper($totpCode);
@@ -89,18 +195,60 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($isValid) {
// Volledige login succesvol
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();
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
// Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag)
issue_day_session_token((int)$user['id']);
// Registreer actieve sessie in user_sessions (SOC 2 / ISO 27001)
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA', 'was_new_ip' => $wasNewIp], 'SUCCESS', $user['email']);
unset(
$_SESSION['pending_2fa_user_id'],
$_SESSION['pending_2fa_user_email'],
$_SESSION['pending_2fa_user_name'],
$_SESSION['pending_new_ip'],
$_SESSION['pending_new_ip_mail_sent']
);
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.";
}
}
@@ -115,28 +263,71 @@ $pageTitle = "Fociz — Inloggen";
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage vóór DOM render -->
<script>
(function() {
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
var isDark = true;
if (activeTheme === 'dark') isDark = true;
else if (activeTheme === 'light') isDark = false;
else if (activeTheme === 'system') {
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
if (isDark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: {
850: '#151f32',
950: '#070b12',
}
}
}
}
}
</script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
<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-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
<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-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
<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-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
<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="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-3xl font-extrabold tracking-tight text-white">Fociz</h1>
<p class="text-slate-400 text-sm mt-1">
<h1 class="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Fociz</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
Veilig inloggen met tweestapsverificatie (2FA)
</p>
</div>
<?php if ($infoMessage): ?>
<div class="mb-6 p-4 rounded-2xl bg-sky-500/10 border border-sky-500/30 flex items-start space-x-3 text-sky-800 dark:text-sky-300 text-xs sm:text-sm">
<i data-lucide="info" class="w-5 h-5 flex-shrink-0 mt-0.5 text-sky-600 dark:text-sky-400"></i>
<span><?= htmlspecialchars($infoMessage) ?></span>
</div>
<?php endif; ?>
<?php if ($error): ?>
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
<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>
@@ -145,21 +336,25 @@ $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-medium text-slate-300 mb-1">E-mailadres</label>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres</label>
<div class="relative">
<input type="email" name="email" required autofocus placeholder="uw.naam@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
<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>
<div>
<label class="block text-xs font-medium 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-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
<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>
</div>
<button type="submit" class="w-full mt-4 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<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">
<span>Verder naar 2FA</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</button>
@@ -169,30 +364,51 @@ $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-emerald-500/10 text-emerald-400 mb-2">
<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>
</div>
<p className="text-sm text-slate-300 font-medium">
<p class="text-sm text-slate-800 dark:text-slate-300 font-semibold">
Voer uw 6-cijferige verificatiecode in
</p>
<p class="text-xs text-slate-400 mt-1">
Gekoppeld aan <strong class="text-slate-200"><?= htmlspecialchars($_SESSION['pending_2fa_user_email'] ?? '') ?></strong>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
Gekoppeld aan <strong class="text-slate-900 dark:text-slate-200"><?= htmlspecialchars($_SESSION['pending_2fa_user_email'] ?? '') ?></strong>
</p>
</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-800/80 border border-slate-700 rounded-xl py-3 text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
<span class="text-[11px] text-slate-500 text-center block mt-1">
Tip: U kunt ook een 8-cijferige herstelcode invoeren.
</span>
<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">
<a href="/auth/login.php?action=restart" class="w-1/3 text-center bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium py-2.5 px-3 rounded-xl transition text-sm flex items-center justify-center">
<a href="/auth/login.php?action=restart" class="w-1/3 text-center bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-medium py-2.5 px-3 rounded-xl transition text-sm flex items-center justify-center cursor-pointer">
Terug
</a>
<button type="submit" class="w-2/3 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<button type="submit" class="w-2/3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<span>Inloggen</span>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
</button>
@@ -201,14 +417,17 @@ $pageTitle = "Fociz — Inloggen";
<?php endif; ?>
<div class="mt-8 pt-6 border-t border-slate-800/80 text-center text-xs text-slate-500">
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center text-xs text-slate-500">
Toegang tot Fociz is strikt op uitnodiging. Vraag uw beheerder om een uitnodigingslink.
</div>
</div>
<script>
try {
localStorage.removeItem('fociz_session_last_activity');
localStorage.removeItem('fociz_session_logged_out');
} catch(e) {}
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
</body>
</html>
+19
View File
@@ -6,6 +6,25 @@
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
// Vernietig persistente login en sessie in database (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
if (!empty($_SESSION['user_id'])) {
$uid = (int)$_SESSION['user_id'];
try {
$db = get_db_connection();
ensure_remember_token_columns($db);
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
$stmt->execute([$uid]);
fociz_terminate_session(session_id());
fociz_log_audit($uid, 'SESSION_TERMINATED_LOGOUT', 'users', $uid, ['ip' => fociz_get_client_ip()], 'SUCCESS', $_SESSION['user_email'] ?? null);
} catch (Throwable $e) {
// Stil negeren bij uitloggen
}
}
// Verwijder remember cookie
clear_remember_cookie();
// Vernietig alle sessie variabelen
$_SESSION = [];
+32 -16
View File
@@ -9,35 +9,51 @@ require_once __DIR__ . '/../includes/integrations/microsoft.php';
$user = require_auth();
$db = get_db_connection();
// Haal eventueel opgeslagen client_id en tenant_id op uit database
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$user['id']]);
$row = $stmt->fetch();
$savedSettings = json_decode($row['settings'] ?? '{}', true) ?: [];
// Haal centrale Microsoft app configuratie op (env of admin instellingen)
$appConfig = microsoft_get_app_config();
$clientId = !empty($_GET['client_id'])
? trim($_GET['client_id'])
: (!empty($savedSettings['client_id']) ? $savedSettings['client_id'] : fociz_env('MICROSOFT_CLIENT_ID', ''));
$clientId = !empty($_GET['client_id']) ? trim($_GET['client_id']) : $appConfig['client_id'];
$tenantId = !empty($_GET['tenant_id']) ? trim($_GET['tenant_id']) : $appConfig['tenant_id'];
$tenantId = !empty($_GET['tenant_id'])
? trim($_GET['tenant_id'])
: (!empty($savedSettings['tenant_id']) ? $savedSettings['tenant_id'] : fociz_env('MICROSOFT_TENANT_ID', 'common'));
// Voorkom AADSTS50194: single-tenant Azure applicaties mogen NOOIT het /common endpoint gebruiken
if (empty($tenantId) || $tenantId === 'common') {
$userEmail = $user['email'] ?? '';
$parts = explode('@', $userEmail);
$domain = strtolower(trim(end($parts)));
if (!empty($domain) && !in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) {
$tenantId = $domain;
} else {
$tenantId = 'planningsagenda.nl';
}
}
if (empty($clientId)) {
header('Location: /integrations.php?error=missing_ms_client_id');
$err = ($user['role'] === 'ADMIN')
? 'Vul eerst de Application (Client) ID en het Client Secret in bij het Systeembeheer om te koppelen met Microsoft 365.'
: 'Microsoft 365 is nog niet geconfigureerd door de beheerder van uw organisatie. Vraag uw beheerder om de koppeling in te stellen.';
header('Location: /profile.php?error=' . urlencode($err));
exit;
}
// Bepaal de dynamische of geconfigureerde redirect URI
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
// Genereer unieke CSRF state
$state = bin2hex(random_bytes(16));
// Genereer cryptografisch ondertekende OAuth state waarin de user_id en timestamp veilig zijn opgeslagen
$state = generate_oauth_state((int)$user['id']);
$_SESSION['ms_oauth_state'] = $state;
$_SESSION['ms_oauth_user_id'] = $user['id'];
$_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>
+96 -36
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,14 +62,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$adminId = (int)$db->lastInsertId();
// Automatische sessie starten
// 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();
@@ -87,28 +104,59 @@ $pageTitle = "Fociz — Eerste Initialisatie";
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage -->
<script>
(function() {
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
var isDark = true;
if (activeTheme === 'dark') isDark = true;
else if (activeTheme === 'light') isDark = false;
else if (activeTheme === 'system') {
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
if (isDark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: { 850: '#151f32', 950: '#070b12' }
}
}
}
}
</script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
<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-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
<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-xl bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
<div class="w-full max-w-xl bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
<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="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-3xl font-bold tracking-tight text-white">Fociz Initialisatie</h1>
<p class="text-slate-400 text-sm mt-1">
<h1 class="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Fociz Initialisatie</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
Eenmalige configuratie van het hoofdbeheerdersaccount met verplichte 2FA
</p>
</div>
<?php if ($error): ?>
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
<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>
@@ -117,26 +165,26 @@ $pageTitle = "Fociz — Eerste Initialisatie";
<?php if ($success): ?>
<!-- Stap 3: Voltooid met backup codes -->
<div class="text-center space-y-6">
<div class="w-16 h-16 bg-emerald-500/20 text-emerald-400 rounded-full flex items-center justify-center mx-auto border border-emerald-500/40">
<div class="w-16 h-16 bg-teal-50 dark:bg-teal-950/40 text-teal-600 dark:text-teal-400 rounded-2xl flex items-center justify-center mx-auto border border-teal-200 dark:border-teal-800/60 shadow-xs">
<i data-lucide="check-circle-2" class="w-10 h-10"></i>
</div>
<div>
<h2 class="text-2xl font-bold text-white mb-1">Beheerder Geïnstalleerd!</h2>
<p class="text-slate-400 text-sm">
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-1">Beheerder Geïnstalleerd!</h2>
<p class="text-slate-500 dark:text-slate-400 text-sm">
Uw account is aangemaakt en direct beveiligd met 2FA. Bewaar deze eenmalige herstelcodes veilig:
</p>
</div>
<div class="bg-slate-950/90 border border-slate-800 rounded-xl p-4 text-left">
<div class="text-xs font-semibold text-slate-400 mb-2">HERSTELCODES (NOODGEBRUIK):</div>
<div class="grid grid-cols-2 gap-2 font-mono text-xs text-slate-300">
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-2xl p-4 text-left">
<div class="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-2">HERSTELCODES (NOODGEBRUIK):</div>
<div class="grid grid-cols-2 gap-2 font-mono text-xs text-slate-800 dark:text-slate-300">
<?php foreach ($backupCodes as $code): ?>
<span class="bg-slate-900 px-2.5 py-1.5 rounded border border-slate-800 text-center"><?= htmlspecialchars($code) ?></span>
<span class="bg-white dark:bg-[#111622] px-2.5 py-1.5 rounded-xl border border-slate-200 dark:border-slate-800 text-center font-bold"><?= htmlspecialchars($code) ?></span>
<?php endforeach; ?>
</div>
</div>
<a href="/planner.php" class="inline-flex items-center justify-center w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 space-x-2">
<a href="/planner.php" class="inline-flex items-center justify-center w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs space-x-2 cursor-pointer">
<span>Start met Fociz Dagplanning</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
@@ -148,54 +196,54 @@ $pageTitle = "Fociz — Eerste Initialisatie";
<!-- Account Info -->
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Volledige naam</label>
<input type="text" name="name" required placeholder="Bijv. Jan de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Volledige naam</label>
<input type="text" name="name" required placeholder="Bijv. Jan de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Beheerder E-mailadres</label>
<input type="email" name="email" required placeholder="admin@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beheerder E-mailadres</label>
<input type="email" name="email" required placeholder="admin@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
<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-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
<input type="password" name="confirm_password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
</div>
</div>
<!-- 2FA QR Code & Koppeling -->
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center justify-center space-x-1.5 text-emerald-400">
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center space-y-4">
<h3 class="text-sm font-bold uppercase tracking-wider flex items-center justify-center space-x-1.5 text-indigo-600 dark:text-indigo-400">
<i data-lucide="key" class="w-4 h-4"></i>
<span>Tweestapsverificatie (2FA) Koppelen</span>
</h3>
<p class="text-xs text-slate-400 max-w-md mx-auto">
<p class="text-xs text-slate-500 dark:text-slate-400 max-w-md mx-auto">
Scan onderstaande QR-code met uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
</p>
<div class="inline-block p-3 bg-white rounded-2xl shadow-xl">
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-44 h-44 mx-auto">
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
<div id="qrcode" class="w-44 h-44 mx-auto flex items-center justify-center"></div>
</div>
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
<span>Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong></span>
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
<span>Sleutel: <strong class="text-indigo-600 dark:text-indigo-400"><?= htmlspecialchars($secret) ?></strong></span>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1.5">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Voer de 6-cijferige verificatiecode uit uw app in:
</label>
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-2 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
</div>
</div>
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<button type="submit" class="w-full mt-6 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<span>Account & 2FA Bevestigen</span>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
</button>
@@ -204,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
+255 -68
View File
@@ -3,15 +3,23 @@
* Fociz Brainstorm Sessies Overzicht
*/
require_once __DIR__ . '/includes/header.php';
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("
@@ -20,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.";
@@ -28,40 +37,47 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
}
// Haal alle sessies op
$stmtSessions = $db->query("
SELECT s.*, u.name as creator_name,
(SELECT COUNT(*) FROM brainstorm_cards WHERE session_id = s.id) as cards_count
FROM brainstorm_sessions s
JOIN users u ON s.created_by_id = u.id
ORDER BY s.created_at DESC
");
$sessions = $stmtSessions->fetchAll();
$sessions = [];
try {
$stmtSessions = $db->query("
SELECT s.*, u.name as creator_name,
(SELECT COUNT(*) FROM brainstorm_cards WHERE session_id = s.id) as cards_count
FROM brainstorm_sessions s
JOIN users u ON s.created_by_id = u.id
ORDER BY s.created_at DESC
");
$sessions = $stmtSessions->fetchAll() ?: [];
} catch (Throwable $e) {
error_log("Fout bij ophalen brainstorm sessies: " . $e->getMessage());
ensure_tables_exist($db);
}
require_once __DIR__ . '/includes/header.php';
?>
<div class="space-y-6">
<div class="space-y-6 max-w-7xl mx-auto">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
<div class="fociz-card p-6 sm:p-7 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div class="flex items-center space-x-2 text-amber-400 text-xs font-semibold uppercase tracking-wider mb-1">
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
<i data-lucide="lightbulb" class="w-4 h-4"></i>
<span>Creatie & Innovatie</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
Brainstorm Sessies
</h1>
<p class="text-slate-400 text-xs sm:text-sm mt-1">
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
Brainstorm met collega's, verzamel ideeën en knelpunten, en transformeer ze direct tot een werkbare roadmap.
</p>
</div>
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-amber-500 hover:bg-amber-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-amber-500/20 flex-shrink-0">
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs flex-shrink-0 cursor-pointer">
<i data-lucide="plus" class="w-4 h-4"></i>
<span>Nieuwe Sessie Starten</span>
</button>
</div>
<?php if ($error): ?>
<div class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-700 dark:text-rose-400 text-sm">
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
@@ -69,97 +85,163 @@ $sessions = $stmtSessions->fetchAll();
<!-- Grid met sessies -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<?php if (empty($sessions)): ?>
<div class="col-span-full bg-slate-900/60 border border-slate-800/80 rounded-2xl p-12 text-center">
<i data-lucide="lightbulb" class="w-12 h-12 text-amber-500/40 mx-auto mb-3"></i>
<h3 class="text-base font-bold text-white">Nog geen brainstormsessies gestart</h3>
<p class="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
<div class="col-span-full fociz-card p-12 text-center shadow-xs">
<i data-lucide="lightbulb" class="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3"></i>
<h3 class="text-base font-bold text-slate-900 dark:text-white">Nog geen brainstormsessies gestart</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-sm mx-auto">
Start uw eerste brainstormsessie om ideeën te verzamelen en ze met één klik om te vormen naar planningsitems.
</p>
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="mt-4 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold rounded-xl text-xs transition">
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl text-xs transition cursor-pointer shadow-xs">
Sessie Starten
</button>
</div>
<?php else: ?>
<?php foreach ($sessions as $sess): ?>
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="group bg-slate-900/80 hover:bg-slate-850 border border-slate-800 hover:border-amber-500/40 rounded-2xl p-5 shadow-lg transition flex flex-col justify-between">
<div>
<?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-full bg-amber-500/10 border border-amber-500/20 text-amber-400 font-semibold text-[11px]">
<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']) ?>
</span>
<span class="flex items-center space-x-1 text-slate-500 text-xs">
<span class="flex items-center space-x-1 text-slate-400 dark:text-slate-500 text-xs font-medium">
<i data-lucide="message-square" class="w-3.5 h-3.5"></i>
<span><?= $sess['cards_count'] ?> kaarten</span>
</span>
</div>
<h3 class="text-base font-bold text-white group-hover:text-amber-400 transition leading-snug">
<h3 class="text-base font-bold text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition leading-snug">
<?= htmlspecialchars($sess['title']) ?>
</h3>
<?php if (!empty($sess['description'])): ?>
<p class="text-slate-400 text-xs mt-2 line-clamp-2">
<p class="text-slate-600 dark:text-slate-400 text-xs mt-2 line-clamp-2 leading-relaxed">
<?= htmlspecialchars($sess['description']) ?>
</p>
<?php endif; ?>
</div>
</a>
<div class="mt-5 pt-4 border-t border-slate-800/80 flex items-center justify-between text-xs text-slate-500">
<span>Door <?= htmlspecialchars($sess['creator_name']) ?></span>
<div class="flex items-center space-x-1 text-amber-400 group-hover:translate-x-1 transition font-medium">
<span>Open canvas</span>
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
<div class="mt-5 pt-4 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span class="truncate">Door <?= htmlspecialchars($sess['creator_name']) ?></span>
<div class="flex items-center space-x-2 flex-shrink-0 ml-2">
<?php if ($canDelete): ?>
<button
type="button"
onclick="promptDeleteSession(event, <?= (int)$sess['id'] ?>, '<?= htmlspecialchars(addslashes($sess['title']), ENT_QUOTES) ?>', <?= (int)$sess['cards_count'] ?>)"
class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition cursor-pointer"
title="Sessie verwijderen"
>
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
<?php endif; ?>
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="flex items-center space-x-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-bold">
<span>Open canvas</span>
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
</a>
</div>
</div>
</a>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Modal: Nieuwe sessie -->
<div id="newSessionModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
<h3 class="text-base font-bold text-white flex items-center space-x-2">
<i data-lucide="sparkles" class="w-4 h-4 text-amber-400"></i>
<span>Nieuwe Brainstormsessie</span>
</h3>
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="text-slate-400 hover:text-white">
<i data-lucide="x" class="w-5 h-5"></i>
<div id="newSessionModal" onclick="if(event.target === this) this.classList.add('hidden')" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto">
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-xl w-full my-6 space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center space-x-2.5">
<div class="w-8 h-8 rounded-xl bg-indigo-50 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 flex items-center justify-center border border-indigo-100 dark:border-indigo-800/60">
<i data-lucide="sparkles" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Nieuwe Brainstormsessie</h3>
<p class="text-[11px] text-slate-500 dark:text-slate-400">Creëer een canvas en voed de AI Co-Pilot met achtergrondinformatie</p>
</div>
</div>
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<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-medium text-slate-300 mb-1">Onderwerp / Titel</label>
<input type="text" name="title" required placeholder="Bijv. Q4 Automatisering & Klantreis" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="sm:col-span-2">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
Onderwerp / Titel <span class="text-rose-500">*</span>
</label>
<input type="text" name="title" required placeholder="Bijv. Modernisering Klantportaal & Self-service" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs font-semibold">
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Thema / Categorie</label>
<select name="theme" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<option value="Product & Strategie">Product & Strategie</option>
<option value="Procesverbetering">Procesverbetering</option>
<option value="Klant & Service">Klant & Service</option>
<option value="Technologie & IT">Technologie & IT</option>
<option value="Marketing & Groei">Marketing & Groei</option>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Thema / Categorie</label>
<select name="category" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<?php
$hasActiveCategory = false;
foreach ($brainstormCategories as $bCat):
if (isset($bCat['is_active']) && empty($bCat['is_active'])) continue;
$hasActiveCategory = true;
?>
<option value="<?= htmlspecialchars($bCat['name']) ?>"><?= htmlspecialchars($bCat['name']) ?></option>
<?php endforeach; ?>
<?php if (!$hasActiveCategory): ?>
<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-medium text-slate-300 mb-1">Doel / Beschrijving</label>
<input type="text" name="description" placeholder="Doel van deze sessie..." class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none">
</div>
</div>
<div class="flex space-x-2 pt-2">
<button type="submit" class="flex-1 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition">
Sessie Aanmaken & Canvas Openen
<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 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-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
<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
</button>
</div>
@@ -167,5 +249,110 @@ $sessions = $stmtSessions->fetchAll();
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
<!-- 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'; ?>
+1716 -87
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"
-26
View File
@@ -1,26 +0,0 @@
# MariaDB Verbinding
DB_HOST="192.168.2.33"
DB_PORT="3306"
DB_NAME="focizlive_user"
DB_USER="root"
DB_PASS="F0c1z_Live_Secure_2026!"
# Omgeving: 'development' (knippert rood) of 'live' (knippert groen)
APP_ENV="development"
# Applicatie URL (voor uitnodigingslinks)
APP_URL="https://uw-domein.nl"
# Applicatie Geheime Sleutel (willekeurige tekenreeks)
APP_SECRET="fociz-maria-db-super-secret-key-998877"
# Microsoft 365 / Outlook (Optionele globale defaults; kan ook via web UI worden ingesteld)
MICROSOFT_CLIENT_ID=""
MICROSOFT_CLIENT_SECRET=""
MICROSOFT_TENANT_ID="common"
# Atlassian Jira Cloud (Optionele globale defaults; kan ook via web UI worden ingesteld)
JIRA_DOMAIN=""
JIRA_EMAIL=""
JIRA_API_TOKEN=""
+8
View File
@@ -0,0 +1,8 @@
# Beveiliging van de interne configuratiemap
<IfModule authz_core_module>
Require all denied
</IfModule>
<IfModule !authz_core_module>
Order deny,allow
Deny from all
</IfModule>
+146 -32
View File
@@ -1,36 +1,46 @@
<?php
/**
* Fociz Applicatie- en Databaseconfiguratie (MAIN / LIVE BRANCH)
* Laadt automatisch .env (of .env.example indien .env niet bestaat)
* Fociz Centrale Applicatie- en Databaseconfiguratie
* Alle configuratie staat overzichtelijk en veilig in dit ene bestand.
*/
// Blokkeer directe HTTP toegang tot dit configuratiebestand (Pen Test Beveiliging)
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === 'config.php') {
http_response_code(403);
exit('Toegang geweigerd.');
}
// Stuur direct strenge beveiligingsheaders mee op elk verzoek (Pen Test Beveiliging)
if (!headers_sent()) {
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://unpkg.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'");
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
header_remove('X-Powered-By');
}
@ini_set('expose_php', 'off');
// 1. .env inlezing (controleert eerst server root .env en dan config/.env)
$envVars = [];
// 1. Zoek naar .env of val terug op config/.env.example
$envCandidates = [
__DIR__ . '/../.env',
__DIR__ . '/.env.example',
__DIR__ . '/../.env.example',
];
foreach ($envCandidates as $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) {
$line = trim($line);
// Negeer lege regels en commentaarregels (# of //)
if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, '//')) {
continue;
}
if (strpos($line, '=') !== false) {
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
// Verwijder omlijstende aanhalingstekens (" of ')
$value = trim($value, " \t\n\r\0\x0B\"'");
// Sla op in array en zet in globale PHP variabelen
$envVars[$name] = $value;
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
@@ -38,7 +48,7 @@ foreach ($envCandidates as $envFile) {
}
}
}
break; // Stop zodra we het eerste geldige bestand hebben ingeladen
break; // Eerste gevonden .env bestand is leidend
}
}
@@ -65,30 +75,134 @@ if (!function_exists('fociz_env')) {
}
}
// MariaDB Verbindingsgegevens (Live defaults voor main branch; overschrijfbaar via server .env)
// =========================================================================
// 2. APPLICATIE INSTELLINGEN & OMGEVINGSDETECTIE
// =========================================================================
define('APP_NAME', 'Fociz');
// Automatische omgevingsdetectie:
// 1. Hostname/domeinnaam is altijd leidend voor productiebeveiliging
$detectedHost = strtolower($_SERVER['HTTP_HOST'] ?? '');
$isDevHost = (
str_contains($detectedHost, 'dev.') ||
str_contains($detectedHost, 'localhost') ||
str_contains($detectedHost, '127.0.0.1')
);
$isLiveHost = (
!empty($detectedHost) &&
!$isDevHost &&
(str_contains($detectedHost, 'fociz.nl') || str_contains($detectedHost, 'live'))
);
// Bepaal APP_ENV: op live domeinen (bijv. www.fociz.nl of fociz.nl) ALTIJD 'live'
// zodat een per ongeluk meegekomen dev-configuratie NOOIT productie kan beïnvloeden.
if ($isLiveHost) {
$effectiveEnv = 'live';
} elseif ($isDevHost) {
$effectiveEnv = 'development';
} else {
$effectiveEnv = fociz_env('APP_ENV', 'live');
}
define('APP_ENV', $effectiveEnv);
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
// =========================================================================
// 3. CENTRALE DATABASE CONFIGURATIE (OMGEVINGSAFHANKELIJK & GIT-VEILIG)
// =========================================================================
$isDevEnv = (APP_ENV === 'development');
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!'));
// 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');
// Applicatie instellingen (Live defaults)
define('APP_NAME', 'Fociz');
define('APP_URL', fociz_env('APP_URL', ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'fociz.nl'))));
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
define('APP_ENV', fociz_env('APP_ENV', 'live')); // 'live' voor de main branch
// Bepaal de dynamische of geconfigureerde applicatie URL
if (!function_exists('fociz_detect_app_url')) {
function fociz_detect_app_url(): string {
$envUrl = fociz_env('APP_URL');
if (!empty($envUrl) && !str_contains($envUrl, 'uw-domein.nl')) {
return rtrim($envUrl, '/');
}
// Dynamische detectie op basis van het huidige domein (werkt direct op www.fociz.nl én dev.fociz.nl)
if (!empty($_SERVER['HTTP_HOST'])) {
$isHttps = (
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') ||
(!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] === 'on') ||
(isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443)
);
$scheme = $isHttps ? 'https://' : 'http://';
return $scheme . $_SERVER['HTTP_HOST'];
}
return (defined('APP_ENV') && APP_ENV === 'live') ? 'https://www.fociz.nl' : 'https://dev.fociz.nl';
}
}
define('APP_URL', fociz_detect_app_url());
// Tijdzone
date_default_timezone_set('Europe/Amsterdam');
// Foutrapportage: in live verbergen we fouten voor bezoekers
if (APP_ENV !== 'live') {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
} else {
// =========================================================================
// 3b. SMTP MAIL SERVER CONFIGURATIE
// =========================================================================
define('SMTP_HOST', fociz_env('SMTP_HOST', 'mail.fociz.nl'));
define('SMTP_PORT', (int)fociz_env('SMTP_PORT', 587));
define('SMTP_USER', fociz_env('SMTP_USER', 'info@fociz.nl'));
define('SMTP_PASS', fociz_env('SMTP_PASS', '6swpQSBg5uWFmjar7F6a'));
define('SMTP_SECURE', fociz_env('SMTP_SECURE', 'tls')); // 'tls' (STARTTLS port 587) of 'ssl' (port 465)
define('SMTP_FROM_EMAIL', fociz_env('SMTP_FROM_EMAIL', 'info@fociz.nl'));
define('SMTP_FROM_NAME', fociz_env('SMTP_FROM_NAME', 'Fociz'));
define('SMTP_REPLY_TO', fociz_env('SMTP_REPLY_TO', 'info@fociz.nl'));
// =========================================================================
// 4. VEILIGE FOUTAFHANDELING & PEN TEST BESCHERMING
// =========================================================================
if (APP_ENV === 'live') {
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);
// Registreer een nette noodfoutpagina voor onverhoopte fatale PHP fouten
register_shutdown_function(function() {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
error_log("Fociz Fatal Error: [{$error['type']}] {$error['message']} in {$error['file']}:{$error['line']}");
$showDiagnostics = (isset($_SESSION['user_role']) && $_SESSION['user_role'] === 'ADMIN');
if ($showDiagnostics) {
echo "<div style='font-family: monospace; background: #0c0f17; color: #ef4444; padding: 25px; margin: 20px; border-radius: 12px; border: 1px solid #dc2626;'>
<h3>⚠️ Fociz Systeemfout (Diagnostiek)</h3>
<p><strong>Fout:</strong> " . htmlspecialchars($error['message']) . "</p>
<p><strong>Bestand:</strong> " . htmlspecialchars($error['file']) . " op regel " . $error['line'] . "</p>
</div>";
} else {
echo "<div style='font-family: sans-serif; background: #0c0f17; color: #f8fafc; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
<div style='max-width: 480px; text-align: center; padding: 40px;'>
<div style='width: 48px; height: 48px; margin: 0 auto 16px; border-radius: 12px; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); display: flex; align-items: center; justify-content: center; color: #818cf8; font-weight: bold;'>F</div>
<h2 style='font-size: 20px; margin-bottom: 8px;'>Er is iets misgegaan</h2>
<p style='color: #94a3b8; font-size: 14px; line-height: 1.5;'>De pagina kon niet worden geladen. Probeer de pagina te vernieuwen of log opnieuw in.</p>
<a href='/auth/login.php' style='display: inline-block; margin-top: 20px; padding: 10px 20px; border-radius: 8px; background: #4f46e5; color: white; text-decoration: none; font-size: 13px; font-weight: 600;'>Naar Inloggen</a>
</div>
</div>";
}
}
});
} else {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
}
+228 -55
View File
@@ -1,8 +1,14 @@
<?php
/**
* MariaDB PDO Databaseverbinding & Automatische Migratie
* MariaDB PDO Databaseverbinding & Automatische Tabellencontrole
*/
// Blokkeer directe HTTP toegang (Pen Test Beveiliging)
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === 'database.php') {
http_response_code(403);
exit('Toegang geweigerd.');
}
require_once __DIR__ . '/config.php';
function get_db_connection(): PDO {
@@ -15,17 +21,18 @@ function get_db_connection(): PDO {
// 1. Controleer of de PDO MySQL/MariaDB driver aanwezig is in PHP
$availableDrivers = PDO::getAvailableDrivers();
if (!in_array('mysql', $availableDrivers)) {
$driversList = !empty($availableDrivers) ? implode(', ', $availableDrivers) : 'geen';
error_log("PDO MySQL driver (pdo_mysql) ontbreekt in PHP.");
die("
<div style='font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; background: #090d16; color: #f1f5f9; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px;'>
<div style='max-width: 580px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.5);'>
<h2 style='color: #ef4444; margin-top: 0;'>⚠️ PHP MySQL Driver Ontbreekt (pdo_mysql)</h2>
<div style='font-family: sans-serif; background: #0c0f17; color: #f1f5f9; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px;'>
<div style='max-width: 580px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px;'>
<h2 style='color: #ef4444; margin-top: 0;'>⚠️ Database Driver Ontbreekt</h2>
<p style='color: #94a3b8; font-size: 14px;'>De extensie <strong>pdo_mysql</strong> is niet actief in deze PHP-omgeving.</p>
</div>
</div>
");
}
$tzOffset = date('P'); // Dynamische offset conform Europe/Amsterdam (+02:00 in zomertijd, +01:00 in wintertijd)
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
@@ -35,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',
@@ -45,19 +55,18 @@ function get_db_connection(): PDO {
);
// 2. CHECK: Bestaat de database al?
// We proberen eerst direct verbinding te maken met de database.
// Als de database al bestaat, lukt dit direct zonder database-aanmaakrechten te vereisen!
try {
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
// De database bestaat al! Controleer enkel of de tabellen al aanwezig zijn
// 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;
} catch (PDOException $e) {
error_log("MariaDB Verbindingsfout [{$e->getCode()}]: {$e->getMessage()}");
// Foutcode 1049 betekent: Database 'DB_NAME' bestaat nog NIET op de MariaDB server
if ($e->getCode() == 1049 || str_contains($e->getMessage(), 'Unknown database')) {
// Probeer de database eenmalig aan te maken indien de gebruiker CREATE rechten heeft
try {
$serverDsn = sprintf('mysql:host=%s;port=%s;charset=%s', DB_HOST, DB_PORT, DB_CHARSET);
$serverPdo = new PDO($serverDsn, DB_USER, DB_PASS, $options);
@@ -66,77 +75,241 @@ function get_db_connection(): PDO {
DB_NAME
));
// Nu opnieuw verbinden met de nieuw aangemaakte database
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
$pdo->exec("SET time_zone = '{$tzOffset}'");
ensure_tables_exist($pdo);
return $pdo;
} catch (PDOException $createEx) {
die("
<div style='font-family: sans-serif; background: #090d16; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
<div style='max-width: 500px; background: #111827; border: 1px solid #f59e0b; border-radius: 16px; padding: 30px; text-align: left;'>
<h2 style='color: #f59e0b; margin-top: 0;'>⚠️ Database '" . htmlspecialchars(DB_NAME) . "' bestaat nog niet</h2>
<p style='color: #94a3b8; font-size: 14px; line-height: 1.6;'>
De database <strong>" . htmlspecialchars(DB_NAME) . "</strong> bestaat nog niet op MariaDB server <strong>" . htmlspecialchars(DB_HOST) . "</strong> en de gebruiker <em>" . htmlspecialchars(DB_USER) . "</em> heeft geen rechten om automatisch databases aan te maken.
</p>
<p style='color: #cbd5e1; font-size: 13px;'>
Maak de database <code>" . htmlspecialchars(DB_NAME) . "</code> eenmalig aan via phpMyAdmin, HeidiSQL of de MariaDB CLI:
</p>
<code style='display: block; background: #000; color: #4ade80; padding: 10px; border-radius: 8px; font-size: 12px; margin: 10px 0;'>
CREATE DATABASE IF NOT EXISTS `" . htmlspecialchars(DB_NAME) . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
</code>
</div>
</div>
");
error_log("Kan database " . DB_NAME . " niet automatisch aanmaken: " . $createEx->getMessage());
}
}
// Andere verbindingsfout (bijv. verkeerd wachtwoord, poort of host onbereikbaar)
die("
<div style='font-family: sans-serif; background: #090d16; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
<div style='max-width: 520px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px; text-align: left;'>
<h2 style='color: #ef4444; margin-top: 0;'>MariaDB Verbindingsfout (" . htmlspecialchars((string)$e->getCode()) . ")</h2>
<p style='color: #cbd5e1; font-size: 14px;'>" . htmlspecialchars($e->getMessage()) . "</p>
<div style='background: #0b0f17; border: 1px solid #1f2937; padding: 15px; border-radius: 10px; font-size: 13px; margin-top: 15px;'>
Host: <strong>" . htmlspecialchars(DB_HOST) . "</strong><br>
Poort: <strong>" . htmlspecialchars(DB_PORT) . "</strong><br>
Database: <strong>" . htmlspecialchars(DB_NAME) . "</strong><br>
Gebruiker: <strong>" . htmlspecialchars(DB_USER) . "</strong>
// Pen Test beveiliging: in live omgeving geen interne IP's of database-details tonen!
if (defined('APP_ENV') && APP_ENV === 'live') {
die("
<div style='font-family: sans-serif; background: #0c0f17; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
<div style='max-width: 480px; background: #111827; border: 1px solid #334155; border-radius: 16px; padding: 30px; text-align: center;'>
<div style='width: 48px; height: 48px; margin: 0 auto 16px; border-radius: 12px; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); display: flex; align-items: center; justify-content: center; color: #f87171; font-weight: bold;'>⚠️</div>
<h2 style='color: #f87171; margin-top: 0; font-size: 18px;'>Database Tijdelijk Niet Bereikbaar</h2>
<p style='color: #94a3b8; font-size: 13px; line-height: 1.6;'>
Er kon geen veilige verbinding tot stand worden gebracht met de database. Probeer het over enkele ogenblikken opnieuw.
</p>
</div>
</div>
</div>
");
");
} else {
die("
<div style='font-family: sans-serif; background: #0c0f17; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
<div style='max-width: 520px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px; text-align: left;'>
<h2 style='color: #ef4444; margin-top: 0;'>MariaDB Verbindingsfout (" . htmlspecialchars((string)$e->getCode()) . ")</h2>
<p style='color: #cbd5e1; font-size: 14px;'>" . htmlspecialchars($e->getMessage()) . "</p>
<div style='background: #0b0f17; border: 1px solid #1f2937; padding: 15px; border-radius: 10px; font-size: 13px; margin-top: 15px;'>
Host: <strong>" . htmlspecialchars(DB_HOST) . "</strong><br>
Poort: <strong>" . htmlspecialchars(DB_PORT) . "</strong><br>
Database: <strong>" . htmlspecialchars(DB_NAME) . "</strong><br>
Gebruiker: <strong>" . htmlspecialchars(DB_USER) . "</strong>
</div>
</div>
</div>
");
}
}
}
/**
* Zorgt ervoor dat alle benodigde tabellen uit schema.sql aanwezig zijn in MariaDB
* Voert niets uit als de tabellen al bestaan!
* Zorgt ervoor dat ALLE benodigde tabellen en kolommen uit schema.sql aanwezig zijn in MariaDB
* Voert elke tabel individueel uit zodat ontbrekende tabellen direct aangemaakt worden.
*/
function ensure_tables_exist(PDO $pdo): void {
try {
// Controleer of de hoofdtabel 'users' al bestaat
$result = $pdo->query("SHOW TABLES LIKE 'users'");
if ($result && $result->rowCount() > 0) {
// Tabellen bestaan al! Geen actie nodig.
return;
}
static $tablesChecked = false;
if ($tablesChecked) return;
$tablesChecked = true;
// Tabellen ontbreken nog: voer schema.sql uit statement voor statement
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
$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)));
foreach ($statements as $stmt) {
// Verwijder eventuele SQL commentaren aan het begin
$stmtClean = preg_replace('/^--.*$/m', '', $stmt);
$stmtClean = trim($stmtClean);
if (!empty($stmtClean)) {
$pdo->exec($stmtClean);
try {
$pdo->exec($stmtClean);
} catch (Throwable $t) {
// Stil negeren indien tabel of relatie al bestaat
}
}
}
}
} catch (Exception $e) {
error_log("Fout bij aanmaken tabellen: " . $e->getMessage());
// Zorg ervoor dat alle kolommen en upgrades aanwezig zijn
$colUpgrades = [
"ALTER TABLE tasks MODIFY external_id VARCHAR(255) NULL",
"ALTER TABLE tasks ADD COLUMN external_url TEXT NULL AFTER external_id",
"ALTER TABLE users ADD COLUMN remember_token VARCHAR(64) NULL AFTER backup_codes",
"ALTER TABLE users ADD COLUMN remember_token_expires_at DATETIME NULL AFTER remember_token",
"ALTER TABLE users ADD COLUMN notification_settings TEXT NULL AFTER remember_token_expires_at",
"ALTER TABLE users ADD COLUMN theme VARCHAR(20) NOT NULL DEFAULT 'dark' AFTER notification_settings",
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255) NULL AFTER theme",
"ALTER TABLE users MODIFY COLUMN avatar_url MEDIUMTEXT NULL",
"ALTER TABLE users ADD COLUMN backup_codes TEXT NULL AFTER two_factor_enabled",
"ALTER TABLE users ADD COLUMN default_jira_filter_code VARCHAR(50) NULL AFTER avatar_url",
"ALTER TABLE users ADD COLUMN fociz_widget_layout TEXT NULL AFTER default_jira_filter_code",
"ALTER TABLE users ADD COLUMN last_mail_check_at DATETIME NULL AFTER fociz_widget_layout",
"ALTER TABLE users ADD COLUMN last_jira_check_at DATETIME NULL AFTER last_mail_check_at",
"ALTER TABLE board_card_schedules ADD UNIQUE KEY uk_sched_card (card_id)",
"CREATE TABLE IF NOT EXISTS `password_resets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `email` VARCHAR(191) NOT NULL, `token` VARCHAR(64) NOT NULL UNIQUE, `expires_at` DATETIME NOT NULL, `used_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`email`), INDEX (`token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS `jira_queue_filters` (`id` INT AUTO_INCREMENT PRIMARY KEY, `filter_code` VARCHAR(50) NOT NULL UNIQUE, `filter_name` VARCHAR(120) NOT NULL, `project_key` VARCHAR(50) NULL, `jql_clause` TEXT NOT NULL, `description` VARCHAR(255) NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `sort_order` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// Jira: Meerdere wachtrijfilters per teamlid
"ALTER TABLE users ADD COLUMN default_jira_filter_codes TEXT NULL AFTER default_jira_filter_code",
// V2.0: Subtaken & Checklists
"CREATE TABLE IF NOT EXISTS `task_subtasks` (`id` INT AUTO_INCREMENT PRIMARY KEY, `task_id` INT NOT NULL, `user_id` INT NOT NULL, `title` VARCHAR(255) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`task_id`, `position`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// Beveiliging: IP tracking & locatie-verificatie
"ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45) NULL AFTER last_jira_check_at",
"ALTER TABLE users ADD COLUMN last_login_at DATETIME NULL AFTER last_login_ip",
"CREATE TABLE IF NOT EXISTS `user_known_ips` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `is_verified` TINYINT(1) NOT NULL DEFAULT 1, `verify_token` VARCHAR(64) NULL, `verify_token_expires_at` DATETIME NULL, `first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`), INDEX (`verify_token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// SOC 2 & ISO 27001: Onwijzigbare Audit Trail
"CREATE TABLE IF NOT EXISTS `audit_logs` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NULL, `user_email` VARCHAR(191) NULL, `action` VARCHAR(64) NOT NULL, `resource_type` VARCHAR(64) NULL, `resource_id` INT NULL, `details` TEXT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`user_id`), INDEX (`action`), INDEX (`created_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// Agenda & Verlof: Hele of meerdere dagen blokkeren (Vakantie, Vrije dag, Ziekte)
"CREATE TABLE IF NOT EXISTS `calendar_blocks` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `start_date` DATE NOT NULL, `end_date` DATE NOT NULL, `title` VARCHAR(255) NOT NULL, `block_type` VARCHAR(50) NOT NULL DEFAULT 'VACATION', `notes` TEXT NULL, `outlook_event_id` VARCHAR(255) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `start_date`, `end_date`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// Centrale Systeeminstellingen (AI, Werkdagen, Sessiebeveiliging)
"CREATE TABLE IF NOT EXISTS `system_settings` (`setting_key` VARCHAR(64) PRIMARY KEY, `setting_value` LONGTEXT NULL, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"ALTER TABLE `system_settings` MODIFY COLUMN `setting_value` LONGTEXT NULL",
// Brainstorm kaarten toewijzen aan collega's en agenda-inplanning
"ALTER TABLE brainstorm_cards ADD COLUMN assignee_id INT NULL AFTER color",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_date DATE NULL AFTER assignee_id",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_start_time VARCHAR(10) NULL AFTER scheduled_date",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
// Dashboard Snelle Bullet Notities & Todo Lijst
"CREATE TABLE IF NOT EXISTS `dashboard_bullets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `plan_date` DATE NOT NULL, `content` VARCHAR(500) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `scheduled_task_id` INT NULL, `scheduled_date` DATE NULL, `scheduled_start_time` VARCHAR(10) NULL, `scheduled_end_time` VARCHAR(10) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `plan_date`), INDEX (`scheduled_task_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($colUpgrades as $sql) {
try {
$pdo->exec($sql);
} catch (Throwable $t) {
// Stil negeren indien kolom al bestaat
}
}
// Zorg dat de standaard Jira wachtrijfilters (SD, IN, AAN, GEN, DX) aanwezig zijn
try {
$existingCodes = $pdo->query("SELECT filter_code FROM jira_queue_filters")->fetchAll(PDO::FETCH_COLUMN) ?: [];
$defaultFilters = [
[
'code' => 'SD',
'name' => 'SD — Servicedesk',
'project' => 'SD',
'jql' => 'project = "SD"',
'desc' => 'Servicedesk supportvragen en operationele meldingen.',
'order' => 1
],
[
'code' => 'IN',
'name' => 'IN — Interfaces',
'project' => 'IN',
'jql' => 'project = "IN"',
'desc' => 'Interface-koppelingen, API-endpoints en data-uitwisselingen.',
'order' => 2
],
[
'code' => 'AAN',
'name' => 'AAN — Aanvragen',
'project' => 'AAN',
'jql' => 'project = "AAN"',
'desc' => 'Klant- en gebruikersaanvragen en wijzigingsverzoeken.',
'order' => 3
],
[
'code' => 'GEN',
'name' => 'GEN — General',
'project' => 'GEN',
'jql' => 'project = "GEN"',
'desc' => 'Algemene taken, organisatie en platformprojecten.',
'order' => 4
],
[
'code' => 'DX',
'name' => 'DX — Koppelingen & SSO',
'project' => 'SD',
'jql' => '"Onderwerp logging[dropdown]" in (Koppelingen, SSO-koppeling, Rest-endpoints, Datadump, UWV-koppeling, Migratie)',
'desc' => 'Servicedesk tickets specifiek voor koppelingen en migraties.',
'order' => 5
]
];
$insertFilterStmt = $pdo->prepare("
INSERT INTO jira_queue_filters (filter_code, filter_name, project_key, jql_clause, description, is_active, sort_order)
VALUES (?, ?, ?, ?, ?, 1, ?)
");
foreach ($defaultFilters as $df) {
if (!in_array($df['code'], $existingCodes)) {
$insertFilterStmt->execute([
$df['code'], $df['name'], $df['project'], $df['jql'], $df['desc'], $df['order']
]);
}
}
} catch (Throwable $e) {
// Stil negeren indien tabel nog in opbouw is
}
// Zorg dat er ten minste één standaard bord met standaard lanes aanwezig is
try {
$boardCount = (int)$pdo->query("SELECT COUNT(*) FROM boards")->fetchColumn();
if ($boardCount === 0) {
$firstAdminId = (int)$pdo->query("SELECT id FROM users ORDER BY (role = 'ADMIN') DESC, id ASC LIMIT 1")->fetchColumn();
if ($firstAdminId > 0) {
$pdo->prepare("INSERT INTO boards (title, description, created_by_id) VALUES (?, ?, ?)")
->execute(['Hoofdbord', 'Standaard team- en takenbord voor opdrachten', $firstAdminId]);
$boardId = (int)$pdo->lastInsertId();
// Maak beheerder lid van het bord
$pdo->prepare("INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'ADMIN')")
->execute([$boardId, $firstAdminId]);
// Maak de 4 standaard lanes aan
$defaultLanes = [
['title' => 'Open', 'color' => 'slate', 'pos' => 0],
['title' => 'In uitvoering', 'color' => 'amber', 'pos' => 1],
['title' => 'Testen', 'color' => 'purple', 'pos' => 2],
['title' => 'Klaar', 'color' => 'emerald', 'pos' => 3],
];
$laneStmt = $pdo->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)");
foreach ($defaultLanes as $l) {
$laneStmt->execute([$boardId, $l['title'], $l['color'], $l['pos']]);
}
}
}
} catch (Throwable $e) {
// Stil negeren indien borden tabel nog niet bestaat
}
// Zorg dat eerdere audit logs die in UTC (+00:00) zijn weggeschreven worden gesynchroniseerd naar Europe/Amsterdam (+2 uur)
try {
$stmtCheck = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'audit_logs_tz_fixed' LIMIT 1");
$fixed = $stmtCheck ? $stmtCheck->fetchColumn() : null;
if (!$fixed) {
// Pas bestaande audit logs eenmalig aan zodat de weergave klopt met de lokale tijd
$pdo->exec("UPDATE audit_logs SET created_at = DATE_ADD(created_at, INTERVAL 2 HOUR)");
$pdo->prepare("
INSERT INTO system_settings (setting_key, setting_value, updated_at)
VALUES ('audit_logs_tz_fixed', '1', NOW())
ON DUPLICATE KEY UPDATE setting_value = '1', updated_at = NOW()
")->execute();
}
} catch (Throwable $e) {
// Stil negeren indien system_settings tabel nog niet bestaat
}
@touch($lockFile);
} catch (Throwable $e) {
error_log("Fout bij controleren tabellen: " . $e->getMessage());
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
http_response_code(403);
exit('Toegang geweigerd.');
+211 -2
View File
@@ -10,7 +10,16 @@ CREATE TABLE IF NOT EXISTS `users` (
`two_factor_secret` VARCHAR(120) NULL,
`two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`backup_codes` TEXT NULL,
`avatar_url` VARCHAR(255) NULL,
`remember_token` VARCHAR(64) NULL,
`remember_token_expires_at` DATETIME NULL,
`notification_settings` TEXT NULL,
`avatar_url` MEDIUMTEXT NULL,
`default_jira_filter_code` VARCHAR(50) NULL,
`fociz_widget_layout` TEXT NULL,
`last_mail_check_at` DATETIME NULL,
`last_jira_check_at` DATETIME NULL,
`last_login_ip` VARCHAR(45) NULL,
`last_login_at` DATETIME NULL,
`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;
@@ -28,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,
@@ -54,7 +74,7 @@ CREATE TABLE IF NOT EXISTS `tasks` (
`duration_minutes` INT NULL DEFAULT 30,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`source` VARCHAR(50) NOT NULL DEFAULT 'FOCIZ',
`external_id` VARCHAR(100) NULL,
`external_id` VARCHAR(255) NULL,
`external_url` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -81,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;
@@ -125,4 +151,187 @@ CREATE TABLE IF NOT EXISTS `integrations` (
UNIQUE KEY `uk_user_provider` (`user_id`, `provider`),
CONSTRAINT `fk_integrations_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `system_settings` (
`setting_key` VARCHAR(64) PRIMARY KEY,
`setting_value` TEXT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Fociz Kanban / Trello Borden, Lanes, Kaarten & Tijdspadkoppelingen
CREATE TABLE IF NOT EXISTS `boards` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`created_by_id` INT NOT NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`created_by_id`),
CONSTRAINT `fk_boards_creator` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `board_members` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`board_id` INT NOT NULL,
`user_id` INT NOT NULL,
`role` ENUM('ADMIN', 'MEMBER', 'VIEWER') NOT NULL DEFAULT 'MEMBER',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_board_user` (`board_id`, `user_id`),
CONSTRAINT `fk_bmembers_board` FOREIGN KEY (`board_id`) REFERENCES `boards` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bmembers_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `board_lanes` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`board_id` INT NOT NULL,
`title` VARCHAR(100) NOT NULL,
`color` VARCHAR(30) NOT NULL DEFAULT 'slate',
`position` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`board_id`, `position`),
CONSTRAINT `fk_blanes_board` FOREIGN KEY (`board_id`) REFERENCES `boards` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `board_cards` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`board_id` INT NOT NULL,
`lane_id` INT NOT NULL,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`assignee_id` INT NULL,
`priority` ENUM('P1', 'P2', 'P3') NOT NULL DEFAULT 'P2',
`color` VARCHAR(30) NOT NULL DEFAULT 'yellow',
`position` INT NOT NULL DEFAULT 0,
`created_by_id` INT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`board_id`, `lane_id`, `position`),
INDEX (`assignee_id`),
CONSTRAINT `fk_bcards_board` FOREIGN KEY (`board_id`) REFERENCES `boards` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bcards_lane` FOREIGN KEY (`lane_id`) REFERENCES `board_lanes` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bcards_assignee` FOREIGN KEY (`assignee_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_bcards_creator` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `board_card_schedules` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`card_id` INT NOT NULL,
`schedule_type` ENUM('SINGLE', 'DAILY_RANGE') NOT NULL DEFAULT 'SINGLE',
`start_date` DATE NOT NULL,
`end_date` DATE NOT NULL,
`start_time` VARCHAR(10) NOT NULL DEFAULT '14:00',
`end_time` VARCHAR(10) NOT NULL DEFAULT '15:00',
`days_of_week` VARCHAR(50) NULL DEFAULT '1,2,3,4,5',
`outlook_event_id` VARCHAR(255) NULL,
`outlook_synced_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_sched_card` (`card_id`),
CONSTRAINT `fk_bsched_card` FOREIGN KEY (`card_id`) REFERENCES `board_cards` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `jira_queue_filters` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`filter_code` VARCHAR(50) NOT NULL UNIQUE,
`filter_name` VARCHAR(120) NOT NULL,
`project_key` VARCHAR(50) NULL,
`jql_clause` TEXT NOT NULL,
`description` VARCHAR(255) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `app_cache` (
`cache_key` VARCHAR(128) NOT NULL PRIMARY KEY,
`cache_value` MEDIUMTEXT NOT NULL,
`expires_at` INT UNSIGNED NOT NULL,
INDEX `idx_app_cache_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Fociz V2.0: Subtaken & Checklists per taak
CREATE TABLE IF NOT EXISTS `task_subtasks` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`task_id` INT NOT NULL,
`user_id` INT NOT NULL,
`title` VARCHAR(255) NOT NULL,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`position` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`task_id`, `position`),
CONSTRAINT `fk_subtasks_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_subtasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Fociz Beveiliging: Bekende IP-adressen en verificatie per gebruiker
CREATE TABLE IF NOT EXISTS `user_known_ips` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`ip_address` VARCHAR(45) NOT NULL,
`user_agent` VARCHAR(255) NULL,
`is_verified` TINYINT(1) NOT NULL DEFAULT 1,
`verify_token` VARCHAR(64) NULL,
`verify_token_expires_at` DATETIME NULL,
`first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`),
INDEX (`verify_token`),
CONSTRAINT `fk_known_ips_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Fociz SOC 2 & ISO 27001: Onwijzigbare Audit Trail (Beveiligings- en beheeracties)
CREATE TABLE IF NOT EXISTS `audit_logs` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NULL,
`user_email` VARCHAR(191) NULL,
`action` VARCHAR(64) NOT NULL,
`resource_type` VARCHAR(64) NULL,
`resource_id` INT NULL,
`details` TEXT NULL,
`ip_address` VARCHAR(45) NOT NULL,
`user_agent` VARCHAR(255) NULL,
`status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (`user_id`),
INDEX (`action`),
INDEX (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Fociz SOC 2 CC6.1 & ISO 27001 A.9.4.2: Actieve Sessietracking & Remote Revocation
CREATE TABLE IF NOT EXISTS `user_sessions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`session_id_hash` VARCHAR(64) NOT NULL UNIQUE,
`ip_address` VARCHAR(45) NOT NULL,
`user_agent` VARCHAR(255) NULL,
`device_name` VARCHAR(100) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`last_activity_at` DATETIME NOT NULL,
`expires_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (`user_id`),
INDEX (`session_id_hash`),
INDEX (`expires_at`),
INDEX (`is_active`),
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Fociz Agenda & Verlof: Hele of meerdere dagen blokkeren (Vakantie, Vrije dag, Ziekte)
CREATE TABLE IF NOT EXISTS `calendar_blocks` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`start_date` DATE NOT NULL,
`end_date` DATE NOT NULL,
`title` VARCHAR(255) NOT NULL,
`block_type` VARCHAR(50) NOT NULL DEFAULT 'VACATION',
`notes` TEXT NULL,
`outlook_event_id` VARCHAR(255) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`user_id`, `start_date`, `end_date`),
CONSTRAINT `fk_calblocks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+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>
+983
View File
@@ -0,0 +1,983 @@
<?php
/**
* Fociz AI Co-Pilot Engine (Release v1.5)
* Beheert intelligente assistentie voor Brainstorming en Dagplanning.
*/
require_once __DIR__ . '/http.php';
require_once __DIR__ . '/settings.php';
/**
* STRICT ZERO-DATA LEAKAGE PRIVACY FIREWALL
* Garandeert dat NOOIT data uit Jira, Mail (Outlook) of Microsoft Teams
* doorgestuurd kan worden naar een externe AI provider (Gemini / OpenAI).
*
* @param array $messages Referentie naar de messages array die verzonden wordt
* @return array Aantal en type geactiveerde firewall filters
*/
function fociz_ai_enforce_privacy_firewall(array &$messages): array {
$detections = [
'jira_tickets' => 0,
'jira_urls' => 0,
'teams_links' => 0,
'email_addrs' => 0,
'mail_headers' => 0,
];
$patterns = [
// Jira ticket keys zoals PROJ-1234, ABC-99 (2 tot 10 hoofdletters gevolgd door streepje en cijfers)
'jira_tickets' => '/\b[A-Z]{2,10}-\d+\b/',
// Jira / Atlassian domeinen en URLs
'jira_urls' => '/https?:\/\/[a-zA-Z0-9-]+\.atlassian\.net[^\s]*/i',
// Teams URLs en links
'teams_links' => '/https?:\/\/teams\.microsoft\.com[^\s]*/i',
// E-mailadressen
'email_addrs' => '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/',
// E-mailheaders en Microsoft Outlook / Teams traces
'mail_headers' => '/\b(Subject:|Onderwerp:|From:|Van:|To:|Aan:|CC:|BCC:|FW:|Fwd:|RE:)\s*[^\r\n]+/i',
];
$replacements = [
'jira_tickets' => '[TICKET_AFGESCHERMD]',
'jira_urls' => '[JIRA_URL_AFGESCHERMD]',
'teams_links' => '[TEAMS_LINK_AFGESCHERMD]',
'email_addrs' => '[EMAIL_AFGESCHERMD]',
'mail_headers' => '[MAIL_HEADER_AFGESCHERMD]',
];
foreach ($messages as $idx => $msg) {
if (!isset($msg['content']) || !is_string($msg['content'])) {
continue;
}
$text = $msg['content'];
foreach ($patterns as $key => $pattern) {
$count = 0;
$text = preg_replace($pattern, $replacements[$key], $text, -1, $count);
if ($count > 0) {
$detections[$key] += $count;
}
}
$messages[$idx]['content'] = $text;
}
$totalBlocked = array_sum($detections);
if ($totalBlocked > 0) {
error_log(sprintf(
"[FOCIZ AI FIREWALL] Zero-Data Leakage actief: %d potentiële datalekken geblokkeerd en geanonimiseerd (%s)",
$totalBlocked,
json_encode($detections)
));
}
return $detections;
}
/**
* Voert een gestructureerde chat completion uit met Google Gemini Pro
*
* @param array $messages
* @param array $options
* @param array $aiSettings
* @return array
*/
function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings): array {
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gemini-2.0-flash';
$temperature = $options['temperature'] ?? 0.3;
$maxTokens = max(512, (int)($options['max_tokens'] ?? 2048));
$jsonMode = !empty($options['json_mode']);
if (empty($apiKey)) {
return [
'success' => false,
'error' => 'Geen Google Gemini API-sleutel ingevuld of geconfigureerd.',
];
}
// Google Generative Language API structuur opbouwen
$systemInstructions = [];
$contents = [];
foreach ($messages as $msg) {
$role = $msg['role'] ?? 'user';
$text = (string)($msg['content'] ?? '');
if ($role === 'system') {
$systemInstructions[] = ['text' => $text];
} else {
$contents[] = [
'role' => ($role === 'assistant') ? 'model' : 'user',
'parts' => [['text' => $text]],
];
}
}
if (empty($contents)) {
$contents[] = [
'role' => 'user',
'parts' => [['text' => 'Hallo']],
];
}
$payload = [
'contents' => $contents,
'generationConfig' => [
'temperature' => (float)$temperature,
'maxOutputTokens' => (int)$maxTokens,
],
];
if (!empty($systemInstructions)) {
$payload['system_instruction'] = [
'parts' => $systemInstructions,
];
}
if ($jsonMode) {
$payload['generationConfig']['responseMimeType'] = 'application/json';
}
$cleanModel = preg_replace('#^models/#', '', $model);
// Helper om tekst uit Gemini candidate te extraheren
$extractCandidateText = function(?array $candidate): string {
if (!$candidate || empty($candidate['content']['parts'])) {
return '';
}
$txt = '';
foreach ($candidate['content']['parts'] as $part) {
if (!empty($part['text']) && empty($part['thought'])) {
$txt .= $part['text'];
}
}
if ($txt === '' && !empty($candidate['content']['parts'][0]['text'])) {
$txt = $candidate['content']['parts'][0]['text'];
}
return trim($txt);
};
// Normaliseer modelnaam en stel geldige kandidatenlijst op
$candidateUrls = [];
// Helper om endpoints op te bouwen (prioriteer v1beta voor moderne Gemini modellen)
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
$m = preg_replace('#^models/#', '', trim($m));
if ($m === '') return;
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
if (!in_array($v1beta, $candidateUrls, true)) {
$candidateUrls[] = $v1beta;
}
};
// Voeg het geconfigureerde model toe als eerste kandidaat (mits niet fictief)
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
$addCandidatesFor($cleanModel);
}
// Voeg altijd de snelle, hoog-beschikbare Google AI modellen toe als betrouwbare fallbacks
$addCandidatesFor('gemini-2.0-flash');
$addCandidatesFor('gemini-1.5-flash');
$addCandidatesFor('gemini-2.5-flash');
$addCandidatesFor('gemini-1.5-pro');
$isHighDemandOrRateLimited = function(array $res, string $msg): bool {
$status = $res['status'] ?? 0;
if ($status === 503 || $status === 429) {
return true;
}
$lower = strtolower($msg);
return (
str_contains($lower, 'high demand') ||
str_contains($lower, 'spikes in demand') ||
str_contains($lower, 'temporarily unavailable') ||
str_contains($lower, 'resource has been exhausted') ||
str_contains($lower, 'rate limit') ||
str_contains($lower, 'overloaded') ||
str_contains($lower, 'too many requests')
);
};
$response = null;
$lastError = null;
$successfulModel = $cleanModel;
$finalText = '';
for ($i = 0; $i < count($candidateUrls); $i++) {
$baseUrl = $candidateUrls[$i];
$endpoint = $baseUrl . '?key=' . urlencode($apiKey);
// Doe max 2 pogingen met korte micro-backoff bij een tijdelijke piek (transient spike)
for ($attempt = 1; $attempt <= 2; $attempt++) {
$res = fociz_http_request($endpoint, [
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
'x-goog-api-key' => $apiKey,
],
'body' => $payload,
'timeout' => 25,
]);
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
$extracted = $extractCandidateText($res['json']['candidates'][0]);
// Als er tekst is, of finishReason is geldig (bijv. STOP of MAX_TOKENS)
if ($extracted !== '' || !empty($res['json']['candidates'][0]['finishReason'])) {
$response = $res;
$finalText = ($extracted !== '') ? $extracted : 'OK';
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
$successfulModel = $mName[1];
}
break 2; // Volledig succes, beëindig beide loops
}
}
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
$lastError = $msg;
// Als dit een tijdelijke piek is en we nog een poging over hebben, wacht kort en probeer nogmaals
if ($attempt < 2 && $isHighDemandOrRateLimited($res, $msg)) {
usleep(800000); // 800ms
continue;
}
break;
}
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
$recModel = $recMatch[1];
$recUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$recModel}:generateContent";
if (!in_array($recUrl, $candidateUrls, true)) {
$candidateUrls[] = $recUrl;
}
}
// 1. Model niet gevonden of deprecated -> probeer de volgende kandidaat
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
continue;
}
// 2. HIGH DEMAND / 503 / 429 OVERBELASTING: NIET STOPPEN!
// Schakel direct door naar de volgende fallback kandidaat (bijv. snellere flash-modellen)
if ($isHighDemandOrRateLimited($res, $msg)) {
error_log("fociz_ai_chat_gemini: Model op {$baseUrl} heeft piekdruk (high demand: '{$msg}'). Schakelt automatisch door naar alternatief model...");
continue;
}
// Alleen bij expliciete autorisatiefouten (ongeldige API key 400/401/403) stoppen
if (in_array($res['status'], [400, 401, 403], true)) {
$response = $res;
break;
}
// Voor alle andere fouten (serverfout 500, timeout, netwerkdip): probeer volgende kandidaat
continue;
}
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) {
$listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey);
$listRes = fociz_http_request($listUrl, [
'method' => 'GET',
'headers' => ['x-goog-api-key' => $apiKey],
'timeout' => 10,
]);
if ($listRes['status'] === 200 && !empty($listRes['json']['models'])) {
$availModels = [];
foreach ($listRes['json']['models'] as $mItem) {
$mMethods = $mItem['supportedGenerationMethods'] ?? [];
if (in_array('generateContent', $mMethods, true)) {
$mClean = preg_replace('#^models/#', '', $mItem['name'] ?? '');
if ($mClean) $availModels[] = $mClean;
}
}
// Zoek een passend gemini model
$fallbackModel = null;
foreach ($availModels as $am) {
if (str_contains($am, 'flash')) {
$fallbackModel = $am;
break;
}
}
if (!$fallbackModel) {
foreach ($availModels as $am) {
if (str_contains($am, 'gemini')) {
$fallbackModel = $am;
break;
}
}
}
if ($fallbackModel) {
$dynEndpoint = "https://generativelanguage.googleapis.com/v1beta/models/{$fallbackModel}:generateContent?key=" . urlencode($apiKey);
$dynRes = fociz_http_request($dynEndpoint, [
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
'x-goog-api-key' => $apiKey,
],
'body' => $payload,
'timeout' => 20,
]);
if ($dynRes['status'] === 200 && !empty($dynRes['json']['candidates'][0])) {
$extracted = $extractCandidateText($dynRes['json']['candidates'][0]);
if ($extracted !== '' || !empty($dynRes['json']['candidates'][0]['finishReason'])) {
$response = $dynRes;
$finalText = ($extracted !== '') ? $extracted : 'OK';
$successfulModel = $fallbackModel;
}
} else {
$response = $dynRes;
}
}
}
}
if (!$response || $response['status'] !== 200 || $finalText === '') {
$errMsg = null;
if (!empty($response['json']['error']['message'])) {
$errMsg = $response['json']['error']['message'];
} elseif (!empty($response['error'])) {
$errMsg = $response['error'];
} elseif (!empty($response['body'])) {
$errMsg = "Gemini fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
} else {
$errMsg = $lastError ?: "Gemini API aanroep mislukt";
}
error_log("fociz_ai_chat_gemini fout: {$errMsg}");
return [
'success' => false,
'error' => $errMsg,
];
}
$rawContent = $finalText;
// Indien Gemini het als markdown code block wrapped heeft (bv. ```json ... ```), schoon dit op
if (preg_match('/^```(?:json)?\s*(.*?)\s*```$/s', $rawContent, $m)) {
$cleanContent = trim($m[1]);
} else {
$cleanContent = $rawContent;
}
$parsedJson = null;
if ($jsonMode) {
$parsedJson = json_decode($cleanContent, true);
if ($parsedJson === null && json_last_error() !== JSON_ERROR_NONE) {
// Poging 2: json decode met raw
$parsedJson = json_decode($rawContent, true);
}
// Poging 3: extraheer JSON substring {...} of [...] indien AI conversatietekst heeft toegevoegd
if ($parsedJson === null) {
if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $cleanContent, $matchJson)) {
$parsedJson = json_decode($matchJson[0], true);
} elseif (preg_match('/\[(?:[^\[\]]|(?R))*\]/s', $cleanContent, $matchJson)) {
$parsedJson = json_decode($matchJson[0], true);
}
}
}
$promptTokens = (int)($response['json']['usageMetadata']['promptTokenCount'] ?? 0);
$completionTokens = (int)($response['json']['usageMetadata']['candidatesTokenCount'] ?? 0);
if ($promptTokens === 0 && $completionTokens === 0) {
// Schatting obv lengte indien niet geretourneerd
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
$completionTokens = max(1, (int)(strlen($cleanContent) / 4));
}
return [
'success' => true,
'content' => $cleanContent,
'json' => $parsedJson,
'model' => $successfulModel,
'provider' => 'gemini',
'prompt_tokens' => $promptTokens,
'completion_tokens' => $completionTokens,
'total_tokens' => ($promptTokens + $completionTokens),
];
}
/**
* Voert een gestructureerde chat completion uit met OpenAI
*
* @param array $messages
* @param array $options
* @param array $aiSettings
* @return array
*/
function fociz_ai_chat_openai(array $messages, array $options, array $aiSettings): array {
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gpt-4o-mini';
$temperature = $options['temperature'] ?? 0.3;
$maxTokens = $options['max_tokens'] ?? 1500;
$jsonMode = !empty($options['json_mode']);
if (empty($apiKey)) {
return [
'success' => false,
'error' => 'Geen OpenAI API-sleutel ingevuld of geconfigureerd.',
];
}
$payload = [
'model' => $model,
'messages' => $messages,
'temperature' => $temperature,
'max_tokens' => $maxTokens,
];
if ($jsonMode) {
$payload['response_format'] = ['type' => 'json_object'];
}
$response = fociz_http_request('https://api.openai.com/v1/chat/completions', [
'method' => 'POST',
'headers' => [
'Authorization' => "Bearer {$apiKey}",
'Content-Type' => 'application/json',
],
'body' => $payload,
'timeout' => 20,
]);
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
$errMsg = null;
if (!empty($response['json']['error']['message'])) {
$errMsg = $response['json']['error']['message'];
} elseif (!empty($response['error'])) {
$errMsg = $response['error'];
} elseif (!empty($response['body'])) {
$errMsg = "OpenAI fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
} else {
$errMsg = "OpenAI API aanroep mislukt (HTTP {$response['status']})";
}
error_log("fociz_ai_chat_openai fout: {$errMsg}");
return [
'success' => false,
'error' => $errMsg,
];
}
$rawContent = trim($response['json']['choices'][0]['message']['content']);
$parsedJson = null;
if ($jsonMode) {
$parsedJson = json_decode($rawContent, true);
}
$promptTokens = (int)($response['json']['usage']['prompt_tokens'] ?? 0);
$completionTokens = (int)($response['json']['usage']['completion_tokens'] ?? 0);
if ($promptTokens === 0 && $completionTokens === 0) {
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
$completionTokens = max(1, (int)(strlen($rawContent) / 4));
}
return [
'success' => true,
'content' => $rawContent,
'json' => $parsedJson,
'model' => $response['json']['model'] ?? $model,
'provider' => 'openai',
'prompt_tokens' => $promptTokens,
'completion_tokens' => $completionTokens,
'total_tokens' => ($promptTokens + $completionTokens),
];
}
/**
* Hoofdfunctie: Voert een gestructureerde chat completion uit
* Routeert automatisch naar Google Gemini Pro of OpenAI en dwingt ALTIJD
* de Zero-Data Leakage privacy firewall af.
*
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int, 'user_id' => ?int]
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string, 'provider' => ?string]
*/
function fociz_ai_chat(array $messages, array $options = []): array {
// 1. ABSOLUTE ZERO-DATA LEAKAGE PRIVACY CONTROLE:
// Filter ALLE Jira ticket keys, Atlassian URLs, Teams links en Mail adressen/headers
fociz_ai_enforce_privacy_firewall($messages);
$userId = $options['user_id'] ?? null;
$aiSettings = get_ai_settings($userId);
if (empty($aiSettings['ai_api_key'])) {
return [
'success' => false,
'error' => 'Geen AI API-sleutel geconfigureerd. Stel Google Gemini Pro of OpenAI in via Systeembeheer of je profiel.',
];
}
$provider = strtolower($aiSettings['ai_provider'] ?? 'gemini');
$model = strtolower($aiSettings['ai_model'] ?? '');
// Routering: als provider gemini is of model gemini bevat, stuur naar Google Gemini
if ($provider === 'gemini' || strpos($model, 'gemini') !== false) {
$res = fociz_ai_chat_gemini($messages, $options, $aiSettings);
} else {
$res = fociz_ai_chat_openai($messages, $options, $aiSettings);
}
// Verbruiksstatistieken registreren bij geslaagde aanroep
if (!empty($res['success'])) {
$usedProvider = $res['provider'] ?? $provider;
$usedModel = $res['model'] ?? $model;
$pTokens = (int)($res['prompt_tokens'] ?? 0);
$cTokens = (int)($res['completion_tokens'] ?? 0);
record_ai_usage($usedProvider, $usedModel, $pTokens, $cTokens, $userId);
}
return $res;
}
/**
* Werkt een brainstorm idee uit tot 3-5 concrete, behapbare items
* voor een Kanban Bord, Sprint/Tijdlijn of Dagplanner.
*
* @param string $ideaContent
* @param string $sessionTitle
* @param string $theme
* @param int|null $userId
* @return array ['success' => bool, 'items' => array, 'error' => ?string]
*/
function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '', string $theme = '', ?int $userId = null): array {
$systemPrompt = "Je bent een senior Agile/Scrum product specialist en technisch ontwerper.\n"
. "Je taak is om een brainstorm idee of concept om te zetten in 3 tot 5 concrete, behapbare en direct uitvoerbare taken voor een agile team.\n"
. "Elk item moet een heldere taak zijn die binnen één dag of sprintblok voltooid kan worden.\n\n"
. "Formateer de uitvoer als een JSON-object met exact deze structuur:\n"
. "{\n"
. " \"summary\": \"Korte samenvatting van de aanpak (1 zin)\",\n"
. " \"items\": [\n"
. " {\n"
. " \"title\": \"Actiegerichte titel (bijv: API endpoint ontwerpen voor authenticatie)\",\n"
. " \"description\": \"Korte toelichting en 2 concrete acceptatiecriteria\",\n"
. " \"estimated_minutes\": 45,\n"
. " \"priority\": \"P1\",\n"
. " \"color\": \"indigo\",\n"
. " \"category\": \"Backend\"\n"
. " }\n"
. " ]\n"
. "}\n\n"
. "Regels:\n"
. "- Prioriteit moet 'P1' (hoog/focus), 'P2' (medium) of 'P3' (laag) zijn.\n"
. "- Kleur moet gekozen worden uit: 'indigo', 'blue', 'emerald', 'amber', 'rose', 'purple'.\n"
. "- Duur in minuten: 15, 30, 45, 60, 90 of 120.\n"
. "- Categorie: 'Frontend', 'Backend', 'Design', 'Onderzoek', of 'Testing'.\n"
. "- Retourneer UITSCHLUITEND geldige JSON.";
$userPrompt = "Brainstorm Sessie: " . ($sessionTitle ?: 'Algemeen') . "\n"
. "Thema: " . ($theme ?: 'Productontwikkeling') . "\n\n"
. "Uit te werken idee:\n\"" . $ideaContent . "\"\n\n"
. "Splits dit idee op in 3 tot 5 concrete items.";
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], [
'json_mode' => true,
'temperature' => 0.4,
'max_tokens' => 2000,
'user_id' => $userId,
]);
if (!$aiRes['success']) {
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
// Fallback: Als AI na alle modelpogingen niet reageert, creëer 3 logische taken zodat de workflow direct doorloopt
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
return [
'success' => true,
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
'items' => [
[
'title' => "Onderzoek & specificatie: {$shortTitle}",
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
'estimated_minutes' => 45,
'priority' => 'P1',
'color' => 'indigo',
'category' => 'Onderzoek'
],
[
'title' => "Realisatie & uitwerking: {$shortTitle}",
'description' => "Implementatie en concrete uitvoering van het idee.",
'estimated_minutes' => 90,
'priority' => 'P2',
'color' => 'blue',
'category' => 'Frontend'
],
[
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
'description' => "Testen, feedback verwerken en afronden.",
'estimated_minutes' => 30,
'priority' => 'P2',
'color' => 'emerald',
'category' => 'Testing'
]
],
'model' => 'Smart Fallback',
'is_fallback' => true,
];
}
$parsed = $aiRes['json'] ?? null;
$items = [];
$summary = '';
if (is_array($parsed)) {
if (!empty($parsed['items']) && is_array($parsed['items'])) {
$items = $parsed['items'];
} elseif (!empty($parsed['tasks']) && is_array($parsed['tasks'])) {
$items = $parsed['tasks'];
} elseif (!empty($parsed['taken']) && is_array($parsed['taken'])) {
$items = $parsed['taken'];
} elseif (!empty($parsed['cards']) && is_array($parsed['cards'])) {
$items = $parsed['cards'];
} elseif (array_is_list($parsed)) {
$items = $parsed;
}
$summary = (string)($parsed['summary'] ?? '');
}
// Als JSON parsing toch leeg was, probeer regex fallback op ruwe content
if (empty($items) && !empty($aiRes['content'])) {
if (preg_match('/\[\s*\{.*\}\s*\]/s', $aiRes['content'], $mArr)) {
$decodedArr = json_decode($mArr[0], true);
if (is_array($decodedArr)) {
$items = $decodedArr;
}
}
}
if (empty($items)) {
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
return [
'success' => true,
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
'items' => [
[
'title' => "Onderzoek & specificatie: {$shortTitle}",
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
'estimated_minutes' => 45,
'priority' => 'P1',
'color' => 'indigo',
'category' => 'Onderzoek'
],
[
'title' => "Realisatie & uitwerking: {$shortTitle}",
'description' => "Implementatie en concrete uitvoering van het idee.",
'estimated_minutes' => 90,
'priority' => 'P2',
'color' => 'blue',
'category' => 'Frontend'
],
[
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
'description' => "Testen, feedback verwerken en afronden.",
'estimated_minutes' => 30,
'priority' => 'P2',
'color' => 'emerald',
'category' => 'Testing'
]
],
'model' => 'Smart Fallback',
'is_fallback' => true,
];
}
return [
'success' => true,
'summary' => $summary ?: 'Dit idee is opgesplitst in behapbare deeltaken.',
'items' => $items,
'model' => $aiRes['model'] ?? 'Gemini Pro',
];
}
/**
* Categoriseert sticky notes automatisch in:
* - 'Ideeën'
* - 'Knelpunten'
* - 'Oplossingen'
* - 'Quick Wins'
*/
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards, ?int $userId = null): array {
if (empty($cards)) {
return ['success' => true, 'categorized' => []];
}
$cardItems = [];
foreach ($cards as $c) {
$cardItems[] = [
'id' => $c['id'],
'content' => $c['content'],
'category' => $c['category'] ?? 'Ideeën',
];
}
$systemPrompt = "Je bent een deskundige agile facilitator en brainstorm co-pilot voor software- en productontwikkeling.\n"
. "Je ontvangt een lijst met brainstorm kaartjes voor de sessie: '{$sessionTitle}' (Thema: '{$theme}').\n"
. "Je taak is om elk kaartje in te delen in exact één van deze 4 categorieën:\n"
. "1. 'Ideeën' (nieuwe concepten, functies, wensen)\n"
. "2. 'Knelpunten' (problemen, blokkades, risico's, frustraties)\n"
. "3. 'Oplossingen' (concrete antwoorden op knelpunten, architectuurkeuzes)\n"
. "4. 'Quick Wins' (kleine, makkelijk uitvoerbare verbeteringen met hoge impact)\n\n"
. "Retourneer uitsluitend een JSON-object met het veld 'categorized' dat een array is van objecten met 'id' en 'category'.";
$userPrompt = "Hier zijn de kaartjes:\n" . json_encode($cardItems, JSON_UNESCAPED_UNICODE);
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren'];
}
return ['success' => true, 'categorized' => $aiRes['json']['categorized']];
}
/**
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
*/
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4, ?int $userId = null, string $description = ''): array {
$descContext = !empty($description) ? "\nContext en briefing van de sessie:\n" . trim($description) . "\n" : "";
$systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n"
. "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n"
. $descContext
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een post-it staan).\n"
. "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is.";
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën op basis van de sessie en context."],
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
$ideas = [
"Gebruikerservaring en flow optimaliseren voor {$sessionTitle}",
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
"Koppelingen en integraties verkennen voor snellere afhandeling",
"Feedback- en validatielus inrichten met eindgebruikers"
];
return ['success' => true, 'ideas' => array_slice($ideas, 0, max(2, $count)), 'is_fallback' => true];
}
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
}
/**
* Analyseert de volledige sessie briefing en genereert een complete set post-its verdeeld over:
* - Ideeën & Concepten (amber)
* - Knelpunten & Uitdagingen (rose)
* - Oplossingen & Features (teal)
* - Quick Wins (indigo)
*/
function fociz_ai_generate_session_board(string $sessionTitle, string $theme, string $description = '', ?int $userId = null): array {
$descContext = !empty($description)
? "Uitgebreide sessiebeschrijving en briefing:\n" . trim($description) . "\n\n"
: "Geen aanvullende briefing opgegeven; baseer je op de titel en het thema.\n\n";
$systemPrompt = "Je bent een ervaren agile facilitator en strategisch adviseur.\n"
. "Je analyseert de briefing van een brainstormsessie getiteld '{$sessionTitle}' (Thema: '{$theme}').\n"
. $descContext
. "Jouw taak is om de sessie direct uit te werken tot een rijk, concreet whiteboard met post-its over exact 4 categorieën:\n"
. "1. 'Ideeën': Vernieuwende concepten, functionaliteiten en strategische kansen (2 tot 4 post-its).\n"
. "2. 'Knelpunten': Concreet benoemde knelpunten, risico's, blokkades of gebruikersfrustraties (2 tot 4 post-its).\n"
. "3. 'Oplossingen': Structurele oplossingen, architectuurkeuzes of procesaanpassingen voor de knelpunten (2 tot 4 post-its).\n"
. "4. 'Quick Wins': Laagdrempelige acties die met minimale inspanning direct resultaat opleveren (2 tot 3 post-its).\n\n"
. "Regels per post-it:\n"
. "- Formuleer scherp, helder en direct to-the-point (1-2 zinnen per kaartje alsof het op een fysieke post-it staat).\n"
. "- Sluit naadloos aan op de verstrekte briefing en achtergrondinformatie.\n\n"
. "Retourneer UITSLUITEND een geldig JSON-object met het veld 'cards', waarbij elk item bestaat uit:\n"
. "- 'content': string (de tekst op de post-it)\n"
. "- 'category': exact één van ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins']";
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."],
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['cards']) || !is_array($aiRes['json']['cards'])) {
return [
'success' => true,
'cards' => [
['content' => "Nieuwe visie en kernfunctionaliteit uitwerken voor {$sessionTitle}", 'category' => 'Ideeën'],
['content' => "Gebruikerservaring en self-service flow versterken", 'category' => 'Ideeën'],
['content' => "Onduidelijke randvoorwaarden of ontbrekende specificaties", 'category' => 'Knelpunten'],
['content' => "Handmatige stappen die vertraging en overbelasting veroorzaken", 'category' => 'Knelpunten'],
['content' => "Geautomatiseerd proces en herbruikbare templates inrichten", 'category' => 'Oplossingen'],
['content' => "Duidelijke acceptatiecriteria en testvalidatie vooraf", 'category' => 'Oplossingen'],
['content' => "Quick win: Korte validatiesessie organiseren met het team", 'category' => 'Quick Wins'],
['content' => "Quick win: Dashboard overzicht en checklist direct klaarzetten", 'category' => 'Quick Wins'],
],
'is_fallback' => true,
];
}
return ['success' => true, 'cards' => $aiRes['json']['cards']];
}
/**
* Zet brainstorm kaartjes om in een scherp Roadmap Item
*/
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '', ?int $userId = null): array {
$systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n"
. "Retourneer een JSON-object met:\n"
. "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n"
. "- 'description': Een duidelijke beschrijving van wat er gebouwd moet worden inclusief gebruikerswaarde en 2-3 acceptatiecriteria\n"
. "- 'priority': 'HIGH', 'MEDIUM' of 'LOW'\n"
. "- 'type': 'FEATURE', 'IMPROVEMENT' of 'TECH_DEBT'\n"
. "- 'quarter': 'Q3' of 'Q4'";
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"],
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
// Intelligente fallback bij storing / high demand zodat roadmap conversie nooit faalt
$lines = explode("\n", trim($content));
$firstLine = trim($lines[0] ?? 'Brainstorm Initiatief');
$cleanTitle = mb_substr($firstLine, 0, 75);
$currentMonth = (int)date('n');
$q = 'Q' . ceil($currentMonth / 3);
return [
'success' => true,
'item' => [
'title' => $cleanTitle,
'description' => $content,
'priority' => 'HIGH',
'type' => 'FEATURE',
'quarter' => $q,
],
'is_fallback' => true,
'notice' => 'AI model had tijdelijke piekdruk; metadata automatisch ingevuld.'
];
}
return ['success' => true, 'item' => $aiRes['json']];
}
/**
* Berekent een slimme dagindeling voor open taken rondom geanonimiseerde bezette tijdblokken.
* ZERO-DATA LEAKAGE: Bevat NOOIT meetingtitels, inhoud of e-mailadressen!
*/
function fociz_ai_smart_schedule(array $tasks, array $busyBlocks, array $workdaySettings, ?int $userId = null): array {
$startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0];
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:00')[0];
$lunchStart = $workdaySettings['lunch_start'] ?? '12:30';
$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00';
// Anonimiseer taken: stuur alleen ID, titel (gefilterd door firewall), prioriteit en duur
$sanitizedTasks = array_map(fn($t) => [
'id' => (int)$t['id'],
'title' => (string)$t['title'],
'priority' => $t['priority'] ?? 'P2',
'duration_minutes' => (int)($t['duration_minutes'] ?? 30),
], $tasks);
// Anonimiseer bezette blokken: GEEN titels of deelnemers, enkel tijdstippen!
$sanitizedBusy = array_map(fn($b) => [
'start' => $b['start'] ?? '09:00',
'end' => $b['end'] ?? '10:00',
], $busyBlocks);
$systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n"
. "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n"
. "Regels:\n"
. "1. Plan GEEN taken tijdens bezette blokken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
. "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n"
. "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n"
. "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}.";
$userPrompt = "Bezette tijdsintervallen (agenda):\n" . json_encode($sanitizedBusy, JSON_UNESCAPED_UNICODE) . "\n\n"
. "In te plannen taken:\n" . json_encode($sanitizedTasks, JSON_UNESCAPED_UNICODE);
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen'];
}
return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']];
}
/**
* Genereert een persoonlijke AI dagbriefing op basis van interne Fociz taken.
* ZERO-DATA LEAKAGE: Absoluut GEEN Jira, Mail (Outlook) of Teams data!
*
* @param string $userName Naam van de gebruiker
* @param string $date Datum (Y-m-d)
* @param array $tasks Openstaande interne taken van vandaag
* @param array $busyBlocks Geanonimiseerde bezette tijdsblokken (start/end)
* @param array $unfinishedYest Gisteren niet voltooide taken
* @param int|null $userId
* @return array ['success' => bool, 'greeting' => string, 'focus_task' => string, 'tip' => string, 'warnings' => array]
*/
function fociz_ai_daily_briefing(
string $userName,
string $date,
array $tasks,
array $busyBlocks = [],
array $unfinishedYest = [],
?int $userId = null
): array {
$dutchDays = ['', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
$dayName = $dutchDays[(int)date('N', strtotime($date))] ?? 'vandaag';
$dateLabel = $dayName . ' ' . date('j', strtotime($date)) . ' ' . date('F', strtotime($date));
// Alleen pure Fociz taken samenvatten
$taskSummary = array_map(fn($t) => [
'title' => $t['title'],
'priority' => $t['priority'] ?? 'P2',
'time' => $t['time_block_start'] ?? null,
'duration' => (int)($t['duration_minutes'] ?? 30),
], array_slice($tasks, 0, 10));
$yesterdaySummary = array_map(fn($t) => $t['title'], array_slice($unfinishedYest, 0, 5));
$systemPrompt = "Je bent de persoonlijke AI Co-Pilot van {$userName} in Fociz. "
. "Schrijf een korte, energieke en persoonlijke dagbriefing in het Nederlands voor {$dateLabel}. "
. "Wees direct, concreet en vriendelijk. Gebruik geen markdown headers, geen bullet symbolen zoals *, -, •. "
. "Geef 1-2 zinnen dagopening + 1 zin over de grootste prioriteit + 1 praktische focustip. "
. "Maximaal 60 woorden totaal. "
. "Retourneer een JSON-object met:\n"
. "- 'greeting': openingszin (max 20 woorden)\n"
. "- 'focus_task': de meest urgente taak vandaag (max 15 woorden)\n"
. "- 'tip': één concrete focustip voor vandaag (max 20 woorden)\n"
. "- 'warnings': array van strings, max 2 items (bijv. gisteren niet afgerond of overvolle dag)";
$userPrompt = json_encode([
'date' => $dateLabel,
'tasks_today' => $taskSummary,
'busy_blocks_count' => count($busyBlocks),
'unfinished_yesterday' => $yesterdaySummary,
'total_tasks' => count($tasks),
], JSON_UNESCAPED_UNICODE);
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['json_mode' => true, 'temperature' => 0.6, 'max_tokens' => 300, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen dagbriefing genereren'];
}
return [
'success' => true,
'greeting' => $aiRes['json']['greeting'] ?? "Goedemorgen {$userName}, een nieuwe dag!",
'focus_task' => $aiRes['json']['focus_task'] ?? '',
'tip' => $aiRes['json']['tip'] ?? '',
'warnings' => $aiRes['json']['warnings'] ?? [],
'model' => $aiRes['model'] ?? 'AI',
];
}
+1282 -14
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();
}
+10 -14
View File
@@ -14,32 +14,28 @@
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']);
?>
<!-- FLASHY OMGEVINGSINDICATOR LINKS BOVENIN -->
<div id="fociz-env-badge" class="fixed top-2.5 left-2.5 z-[9999] pointer-events-auto select-none transition-all duration-300 hover:scale-105">
<?php if ($isLive): ?>
<!-- LIVE OMGEVING (GROEN) -->
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/95 border border-emerald-500/70 shadow-lg shadow-emerald-500/25 backdrop-blur-md group">
<!-- Pulsing Green Radar Indicator -->
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-80"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500 shadow-sm shadow-emerald-500"></span>
<!-- LIVE OMGEVING (TEAL) -->
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/90 border border-teal-500/50 shadow-xs backdrop-blur-md group">
<!-- Pulsing Teal Radar Indicator -->
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-teal-500"></span>
</span>
<span class="text-[11px] font-black tracking-wider text-emerald-400 uppercase flex items-center space-x-1">
<span class="text-[10px] font-bold tracking-wider text-teal-400 uppercase flex items-center space-x-1">
<span>Live</span>
</span>
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.2 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.5 rounded bg-teal-500/15 text-teal-300 border border-teal-500/30">
MAIN
</span>
</div>
@@ -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>
+51 -6
View File
@@ -1,18 +1,64 @@
</main>
<footer class="border-t border-slate-800/80 py-6 text-center text-xs text-slate-500">
<div class="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row items-center justify-between gap-2">
<footer class="border-t border-slate-200/80 dark:border-slate-800/60 bg-white/40 dark:bg-transparent backdrop-blur-sm py-5 text-center text-xs text-slate-500 transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col sm:flex-row items-center justify-between gap-2">
<div class="flex items-center space-x-2">
<span class="font-bold text-slate-400">Fociz</span>
<span class="font-bold text-slate-800 dark:text-slate-300">Fociz</span>
<span>&bull;</span>
<span>Focus & Structuur Systeem</span>
<span class="text-slate-500 dark:text-slate-400">Focus & Structuur Systeem</span>
</div>
<div>
<span>Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
<span class="text-slate-400 dark:text-slate-500">Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
</div>
</div>
</footer>
<!-- Fociz Agenda Notificaties Engine -->
<script src="/assets/js/notifications.js"></script>
<?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') {
@@ -21,4 +67,3 @@
</script>
</body>
</html>
+484 -62
View File
@@ -1,18 +1,47 @@
<?php
/**
* Header & Navigatiebalk voor Fociz
* Header & Navigatiebalk voor Fociz (Dual-Mode: Dark & Light, Executive Styling)
*/
require_once __DIR__ . '/auth.php';
$currentUser = $currentUser ?? get_logged_in_user();
$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() {
try {
const serverTheme = <?= json_encode($currentUserTheme ?? 'dark') ?>;
const savedTheme = localStorage.getItem('fociz_theme') || serverTheme || 'dark';
let effective = savedTheme;
if (savedTheme === 'system') {
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (effective === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch(e) {}
})();
</script>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
@@ -22,10 +51,12 @@ $currentPage = basename($_SERVER['PHP_SELF']);
extend: {
colors: {
brand: {
50: '#f0fdf4',
500: '#10b981',
600: '#059669',
700: '#047857',
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
}
}
}
@@ -34,115 +65,506 @@ $currentPage = basename($_SERVER['PHP_SELF']);
</script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Dagsessie Expiratie Watchdog: reset iedere nacht om 23:59:59 en dwingt dagelijks opnieuw inloggen af -->
<script>
(function() {
try {
let hasRedirected = false;
const sessionExpiresAt = <?= strtotime('today 23:59:59') ?> * 1000;
const checkExpiry = function() {
if (hasRedirected) return;
if (Date.now() > sessionExpiresAt + 1000) {
hasRedirected = true;
window.location.href = '/auth/login.php?expired=1';
}
};
const msUntilExpiry = sessionExpiresAt - Date.now();
if (msUntilExpiry > 0) {
setTimeout(checkExpiry, msUntilExpiry + 500);
}
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
checkExpiry();
}
});
window.addEventListener('focus', checkExpiry);
setInterval(checkExpiry, 60000);
} catch(e) {}
})();
</script>
<style>
body {
background-color: #090d16;
color: #f1f5f9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
html.dark body {
background-color: #0c0f17;
color: #f8fafc;
}
html:not(.dark) body {
background-color: #f8fafc;
color: #0f172a;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
letter-spacing: -0.01em;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
html.dark ::-webkit-scrollbar-track { background: #0c0f17; }
html.dark ::-webkit-scrollbar-thumb { background: #1e2638; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #2d3852; }
html:not(.dark) ::-webkit-scrollbar-track { background: #f8fafc; }
html:not(.dark) ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
html:not(.dark) ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* Modern Block & Card Design System */
.fociz-card, .fociz-card-interactive {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(226, 232, 240, 0.9);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.04);
border-radius: 1rem;
backdrop-filter: blur(12px);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
html.dark .fociz-card, html.dark .fociz-card-interactive {
background: rgba(17, 22, 34, 0.88);
border: 1px solid rgba(30, 41, 59, 0.7);
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.35);
}
.fociz-card-interactive {
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.fociz-card-interactive:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.06);
border-color: rgba(99, 102, 241, 0.4);
}
html.dark .fociz-card-interactive:hover {
box-shadow: 0 12px 28px -5px rgba(0, 0, 0, 0.5), 0 0 20px rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.5);
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
</style>
<body class="min-h-screen flex flex-col antialiased selection:bg-emerald-500 selection:text-white">
</head>
<body class="min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white transition-colors duration-150">
<?php
// Include flashy omgevingsindicator (Dev = Rood, Live = Groen)
// Include omgevingsindicator (Dev = Rood, Live = Groen)
require_once __DIR__ . '/env_badge.php';
?>
<?php if ($currentUser): ?>
<header class="sticky top-0 z-40 bg-[#0c121e]/90 backdrop-blur-md border-b border-slate-800/80">
<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 badge vrij staat) -->
<div class="flex items-center space-x-6 pl-28 sm:pl-36">
<a href="/planner.php" class="flex items-center space-x-2.5 group">
<div class="w-9 h-9 rounded-xl bg-gradient-to-tr from-emerald-600 to-teal-400 flex items-center justify-center text-slate-950 font-black text-lg shadow-md shadow-emerald-500/20 group-hover:scale-105 transition">
<!-- 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>
<div class="flex flex-col">
<span class="text-xl font-bold tracking-tight text-white leading-none">Fociz</span>
<span class="text-[10px] text-emerald-400 tracking-wider font-semibold uppercase">Focus & Structuur</span>
<span class="text-lg font-bold tracking-tight text-slate-900 dark:text-white leading-none">Fociz</span>
<span class="text-[9px] text-indigo-600 dark:text-indigo-400 tracking-wider font-semibold uppercase">Focus & Structuur</span>
</div>
</a>
<!-- Nav Tabs -->
<nav class="hidden md:flex items-center space-x-1">
<a href="/planner.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'planner.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>Dagplanning</span>
<a href="/planner.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'planner.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
<span>Fociz</span>
</a>
<a href="/brainstorm.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="lightbulb" class="w-4 h-4"></i>
<span>Brainstorm</span>
<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>
<a href="/roadmap.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'roadmap.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Roadmap</span>
</a>
<a href="/integrations.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'integrations.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="layers" class="w-4 h-4"></i>
<span>Integraties</span>
<!-- 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>
</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>
<!-- 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>
</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>
</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.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'users.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<a href="/users.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'users.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="users" class="w-4 h-4"></i>
<span>Team & Invites</span>
<span>Team</span>
</a>
<a href="/admin.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'admin.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="shield-check" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Beheer</span>
</a>
<?php endif; ?>
</nav>
</div>
<!-- Right side: User & 2FA Status -->
<div class="flex items-center space-x-3">
<div class="hidden sm:flex items-center space-x-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/25 text-emerald-400 text-xs font-medium">
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i>
<span>2FA Actief</span>
</div>
<!-- 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>
<div class="text-right hidden sm:block">
<div class="text-xs font-semibold text-slate-200"><?= htmlspecialchars($currentUser['name']) ?></div>
<div class="text-[10px] text-slate-500">
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?> • <?= htmlspecialchars($currentUser['email']) ?>
<!-- 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; ?>
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 transition">
<!-- Right side: Clean User Profile & Logout -->
<div class="flex items-center space-x-2 sm:space-x-3 shrink-0">
<!-- Profiel link rechtsboven -->
<a href="/profile.php" class="flex items-center space-x-2.5 group hover:opacity-90 transition px-2 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60 shrink-0" title="Mijn Profiel & Instellingen">
<div class="w-7 h-7 rounded-lg bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 shadow-xs overflow-hidden">
<?php if (!empty($currentUser['avatar_url'])): ?>
<img src="<?= htmlspecialchars($currentUser['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-[6px] object-cover bg-white dark:bg-slate-950" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
<div class="hidden w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
</div>
<?php else: ?>
<div class="w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
</div>
<?php endif; ?>
</div>
<div class="text-left hidden sm:block">
<div class="text-xs font-semibold text-slate-800 dark:text-slate-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition leading-tight">
<?= htmlspecialchars($currentUser['name']) ?>
</div>
<div class="text-[10px] text-slate-500 dark:text-slate-400 leading-tight">
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
</div>
</div>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400 dark:text-slate-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition hidden sm:block"></i>
</a>
<!-- Uitloggen -->
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-xl text-slate-400 hover:text-red-600 hover:bg-red-50 dark:text-slate-400 dark:hover:text-red-400 dark:hover:bg-red-500/10 transition shrink-0">
<i data-lucide="log-out" class="w-4 h-4"></i>
</a>
</div>
</div>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-800/60 text-xs">
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="calendar" class="w-4 h-4 mb-0.5"></i>
<span>Planning</span>
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-200/80 dark:border-slate-800/60 text-xs">
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="layout-dashboard" class="w-4 h-4 mb-0.5"></i>
<span>Fociz</span>
</a>
<a href="/brainstorm.php" class="flex flex-col items-center py-1 <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<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>
<span>Brainstorm</span>
</a>
<a href="/roadmap.php" class="flex flex-col items-center py-1 <?= $currentPage === 'roadmap.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<a href="/roadmap.php" class="flex flex-col items-center py-1 <?= $currentPage === 'roadmap.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="map" class="w-4 h-4 mb-0.5"></i>
<span>Roadmap</span>
</a>
<a href="/integrations.php" class="flex flex-col items-center py-1 <?= $currentPage === 'integrations.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<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>Koppelingen</span>
<span>Jira</span>
</a>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/users.php" class="flex flex-col items-center py-1 <?= $currentPage === 'users.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="users" class="w-4 h-4 mb-0.5"></i>
<span>Team</span>
<a href="/admin.php" class="flex flex-col items-center py-1 <?= $currentPage === 'admin.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="shield" class="w-4 h-4 mb-0.5"></i>
<span>Beheer</span>
</a>
<?php endif; ?>
</div>
</div>
</header>
<!-- Fociz Agenda Notificaties Modal (Dual-Mode) -->
<div id="focizNotificationModal" class="hidden fixed inset-0 z-50 bg-black/60 dark:bg-black/80 backdrop-blur-sm items-center justify-center p-4">
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl w-full max-w-md overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<!-- Modal Header -->
<div class="p-5 border-b border-slate-100 dark:border-slate-800/80 flex items-center justify-between">
<div class="flex items-center space-x-2.5">
<div class="w-9 h-9 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-600 dark:text-blue-400 flex items-center justify-center font-bold">
<i data-lucide="bell-ring" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Agenda Push Meldingen</h3>
<p class="text-[11px] text-slate-500 dark:text-slate-400">Persoonlijke waarschuwingen bij actuele afspraken</p>
</div>
</div>
<button type="button" onclick="closeNotificationModal()" class="text-slate-400 hover:text-slate-700 dark:hover:text-white p-1 rounded-lg transition">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<!-- Inhoud Formulier -->
<form onsubmit="saveNotificationPreferences(event)" class="p-5 space-y-4 text-xs">
<!-- Browser Permissie Banner -->
<div id="notifPermGranted" class="hidden p-3 rounded-xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 text-indigo-800 dark:text-indigo-300 flex items-center justify-between">
<div class="flex items-center space-x-2">
<i data-lucide="check-circle" class="w-4 h-4 text-indigo-600 dark:text-indigo-400 flex-shrink-0"></i>
<span class="text-[11px]">Browser pushberichten zijn actief</span>
</div>
<button type="button" onclick="focizTestNotification()" class="px-2 py-1 rounded-lg bg-indigo-100 dark:bg-indigo-500/20 hover:bg-indigo-200 dark:hover:bg-indigo-500/30 text-indigo-800 dark:text-indigo-300 font-semibold text-[10px] transition">
Test Melding
</button>
</div>
<div id="notifPermRequest" class="p-3 rounded-xl bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/30 text-sky-900 dark:text-sky-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<i data-lucide="bell" class="w-4 h-4 text-sky-600 dark:text-sky-400 flex-shrink-0"></i>
<span class="text-[11px]">Toestemming browser vereist</span>
</div>
<button type="button" onclick="focizRequestPermission()" class="px-2.5 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-[10px] transition shadow-xs">
Inschakelen
</button>
</div>
<div id="notifPermDenied" class="hidden p-3 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-800 dark:text-rose-300 text-[11px] leading-relaxed">
<i data-lucide="alert-triangle" class="w-4 h-4 text-rose-600 dark:text-rose-400 inline mr-1 -mt-0.5"></i>
Browser meldingen zijn geblokkeerd. Klik op het slotje links in de adresbalk om meldingen toe te staan.
</div>
<!-- Toggle Meldingen Inschakelen -->
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
<div>
<span class="font-semibold text-slate-900 dark:text-white block">Meldingen Actief</span>
<span class="text-[10px] text-slate-500 dark:text-slate-400">Automatisch waarschuwen bij naderende afspraken</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="notifEnabledInput" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<!-- Vooraankondigingstijd -->
<div>
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
Hoeveel tijd van tevoren waarschuwen?
</label>
<select id="notifLeadTimeSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="0">0 minuten (Precies op aanvangstijd)</option>
<option value="2">2 minuten van tevoren</option>
<option value="5" selected>5 minuten van tevoren (Aanbevolen)</option>
<option value="10">10 minuten van tevoren</option>
<option value="15">15 minuten van tevoren</option>
<option value="30">30 minuten van tevoren</option>
</select>
</div>
<!-- Aantal Herhalingen & Interval Grid -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
Aantal herhalingen
</label>
<select id="notifRepeatCountSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="1">1x (Alleen 1e melding)</option>
<option value="2" selected>2x (Aanbevolen)</option>
<option value="3">3x</option>
<option value="5">5x (Intensief)</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
Herhalingsinterval
</label>
<select id="notifIntervalSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="1">Elke 1 minuut</option>
<option value="2" selected>Elke 2 minuten</option>
<option value="3">Elke 3 minuten</option>
<option value="5">Elke 5 minuten</option>
</select>
</div>
</div>
<!-- Geluidssignaal Toggle -->
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
<div class="flex items-center space-x-2">
<i data-lucide="volume-2" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<div>
<span class="font-semibold text-slate-900 dark:text-white block">Geluidssignaal</span>
<span class="text-[10px] text-slate-500 dark:text-slate-400">Zachte chime afspelen bij melding</span>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="notifSoundInput" checked class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<div id="notifSaveStatus" class="hidden text-indigo-600 dark:text-indigo-400 text-center font-semibold text-[11px] py-1"></div>
<!-- Knoppen -->
<div class="pt-2 flex items-center space-x-2">
<button type="button" onclick="closeNotificationModal()" class="flex-1 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold transition">
Annuleren
</button>
<button type="submit" id="saveNotifBtn" class="flex-1 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold transition shadow-sm shadow-indigo-600/20">
Voorkeuren Opslaan
</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
<script>
/**
* Universele Thema-controller voor Fociz
*/
function focizSetTheme(theme, saveServer = true) {
localStorage.setItem('fociz_theme', theme);
let effective = theme;
if (theme === 'system') {
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (effective === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Update Lucide pictogrammen in header
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Update profiel preview selectoren als aanwezig op pagina
if (typeof updateProfileThemeUI === 'function') {
updateProfileThemeUI(theme);
}
if (saveServer) {
fetch('/api/theme.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: theme })
}).catch(() => {});
}
}
function focizQuickToggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
const nextTheme = isDark ? 'light' : 'dark';
focizSetTheme(nextTheme, true);
}
</script>
<?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);
+3
View File
@@ -0,0 +1,3 @@
<?php
http_response_code(403);
exit('Toegang geweigerd.');
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));
}
}
+337
View File
@@ -0,0 +1,337 @@
<?php
/**
* Centrale Systeeminstellingen Helper voor Fociz
* Beheert organisatie-brede werkdagstandaarden (v1.4) en AI configuratie (v1.5).
*/
require_once __DIR__ . '/../config/database.php';
/**
* Zorgt ervoor dat de system_settings tabel bestaat in MariaDB
*/
function ensure_system_settings_table(?PDO $db = null): void {
static $ensured = false;
if ($ensured) return;
try {
$pdo = $db ?: get_db_connection();
$pdo->exec("
CREATE TABLE IF NOT EXISTS `system_settings` (
`setting_key` VARCHAR(64) PRIMARY KEY,
`setting_value` TEXT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
$ensured = true;
} catch (Throwable $e) {
error_log("ensure_system_settings_table error: " . $e->getMessage());
}
}
/**
* Haalt alle systeeminstellingen in 1 geoptimaliseerde query op en bewaart deze in het static runtime geheugen
*/
function get_all_system_settings(bool $forceRefresh = false): array {
static $settingsCache = null;
if ($settingsCache !== null && !$forceRefresh) {
return $settingsCache;
}
$settingsCache = [];
try {
$db = get_db_connection();
ensure_system_settings_table($db);
$stmt = $db->query("SELECT setting_key, setting_value FROM system_settings");
while ($row = $stmt->fetch()) {
$val = $row['setting_value'];
if ($val === null) {
$settingsCache[$row['setting_key']] = null;
continue;
}
$trimmed = trim((string)$val);
if (str_starts_with($trimmed, '{') || str_starts_with($trimmed, '[')) {
$decoded = json_decode($val, true);
$settingsCache[$row['setting_key']] = (json_last_error() === JSON_ERROR_NONE) ? $decoded : $val;
} else {
$settingsCache[$row['setting_key']] = $val;
}
}
} catch (Throwable $e) {
error_log("get_all_system_settings error: " . $e->getMessage());
}
return $settingsCache;
}
/**
* Haalt een enkele instelling op uit het geheugen (of fallback default)
*/
function get_system_setting(string $key, $default = null) {
$all = get_all_system_settings();
if (array_key_exists($key, $all) && $all[$key] !== null && $all[$key] !== '') {
return $all[$key];
}
return $default;
}
/**
* Slaat een instelling op in MariaDB en ververst direct het cachegeheugen
*/
function set_system_setting(string $key, $value): bool {
try {
$db = get_db_connection();
ensure_system_settings_table($db);
$valToStore = is_array($value) || is_object($value) ? json_encode($value) : (string)$value;
$stmt = $db->prepare("
INSERT INTO system_settings (setting_key, setting_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()
");
$ok = $stmt->execute([$key, $valToStore]);
if ($ok) {
get_all_system_settings(true);
}
return $ok;
} catch (Throwable $e) {
error_log("set_system_setting error for {$key}: " . $e->getMessage());
return false;
}
}
/**
* Haalt alle organisatie werkdag standaarden op (Release v1.4)
*/
function get_workday_settings(): array {
$rawDays = get_system_setting('workdays', '1,2,3,4,5');
$workdays = array_values(array_filter(array_map('intval', explode(',', $rawDays)), fn($d) => $d >= 1 && $d <= 7));
if (empty($workdays)) {
$workdays = [1, 2, 3, 4, 5];
}
return [
'workday_start' => get_system_setting('workday_start', '08:30'),
'workday_end' => get_system_setting('workday_end', '17:00'),
'lunch_start' => get_system_setting('lunch_start', '12:30'),
'lunch_end' => get_system_setting('lunch_end', '13:00'),
'pomodoro_focus' => (int)get_system_setting('pomodoro_focus', 25),
'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50),
'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5),
'default_task_duration' => (int)get_system_setting('default_task_duration', 30),
'workdays' => $workdays,
'workdays_str' => implode(',', $workdays),
];
}
/**
* Haalt AI configuratie-instellingen op (Release v2.0 - Google Gemini Pro & OpenAI)
* Ondersteunt organisatiebrede en optioneel persoonlijke sleutels.
*/
function get_ai_settings(?int $userId = null): array {
if (!function_exists('fociz_decrypt')) {
require_once __DIR__ . '/auth.php';
}
$apiKey = '';
$isPersonal = false;
$personalProvider = null;
$personalModel = null;
// 1. Controleer persoonlijke sleutel van de ingelogde gebruiker
if ($userId !== null && $userId > 0) {
try {
$db = get_db_connection();
$stmt = $db->prepare("SELECT access_token, settings, is_enabled FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') AND is_enabled = 1 ORDER BY id DESC LIMIT 1");
$stmt->execute([$userId]);
$row = $stmt->fetch();
if ($row) {
// Sleutel kan in access_token staan of in settings['api_key']
if (!empty($row['access_token'])) {
$apiKey = fociz_decrypt((string)$row['access_token']);
$isPersonal = true;
} elseif (!empty($row['settings'])) {
$userSettings = json_decode($row['settings'], true) ?: [];
if (!empty($userSettings['api_key'])) {
$apiKey = fociz_decrypt((string)$userSettings['api_key']);
$isPersonal = true;
}
}
if (!empty($row['settings'])) {
$userSettings = json_decode($row['settings'], true) ?: [];
if (!empty($userSettings['provider'])) {
$personalProvider = (string)$userSettings['provider'];
}
if (!empty($userSettings['model'])) {
$personalModel = (string)$userSettings['model'];
}
}
}
} catch (Throwable $e) {
error_log("Fout bij ophalen persoonlijke AI sleutel: " . $e->getMessage());
}
}
// 2. Fallback naar organisatiebrede instellingen
if (empty($apiKey)) {
$savedKeyRaw = get_system_setting('ai_api_key', '');
if (!empty($savedKeyRaw)) {
$apiKey = fociz_decrypt((string)$savedKeyRaw);
}
}
// 3. Fallback naar .env variabelen (GEMINI_API_KEY of OPENAI_API_KEY)
if (empty($apiKey)) {
$apiKey = (string)(fociz_env('GEMINI_API_KEY', '') ?: fociz_env('OPENAI_API_KEY', ''));
}
$provider = !empty($personalProvider) ? $personalProvider : get_system_setting('ai_provider', 'gemini');
if (empty($provider)) {
$provider = 'gemini';
}
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-2.0-flash');
if (empty($model)) {
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-2.0-flash';
}
$apiKey = trim((string)$apiKey);
$hasKey = ($apiKey !== '');
return [
'ai_enabled' => (bool)get_system_setting('ai_enabled', $hasKey),
'ai_provider' => (string)$provider,
'ai_api_key' => $apiKey,
'ai_model' => (string)$model,
'has_key' => $hasKey,
'is_personal_key'=> $isPersonal,
];
}
/**
* Haalt geconfigureerde sessie idle timeout in seconden op conform NIST SP 800-63B / ISO 27001
* Toegestane waarden: 900 (15m), 1800 (30m - default), 3600 (60m), 7200 (120m)
*/
function get_session_idle_timeout(): int {
$timeout = (int)get_system_setting('session_idle_timeout', 1800);
$validTimeouts = [900, 1800, 3600, 7200];
return in_array($timeout, $validTimeouts, true) ? $timeout : 1800;
}
/**
* Haalt de beheerbare dagblokkade-redenen op (bijv. Vakantie, Verlof, Ziek, Focusdag, ADV).
* Standaard voorzien van een duidelijke titel, emoji-icoon en actief-status.
*/
function get_calendar_block_reasons(): array {
$defaultReasons = [
['code' => 'VACATION', 'title' => 'Vakantie', 'icon' => '🏖️', 'is_active' => 1],
['code' => 'LEAVE', 'title' => 'Verlof / Vrij', 'icon' => '✈️', 'is_active' => 1],
['code' => 'SICK', 'title' => 'Ziek / Afwezig', 'icon' => '🤒', 'is_active' => 1],
['code' => 'FOCUS', 'title' => 'Focusdag', 'icon' => '🎯', 'is_active' => 1],
['code' => 'ADV', 'title' => 'ADV Dag', 'icon' => '📋', 'is_active' => 1],
];
$saved = get_system_setting('calendar_block_reasons', null);
if (is_array($saved) && !empty($saved)) {
return $saved;
}
return $defaultReasons;
}
/**
* Haalt de beheerbare brainstorm categorieën op.
* Standaard voorzien van een duidelijke categorienaam, beschrijving en actief-status.
*/
function get_brainstorm_categories(): array {
$defaultCategories = [
['name' => 'Product & Strategie', 'description' => 'Strategische richting, nieuwe producten en visie', 'is_active' => 1],
['name' => 'Procesverbetering', 'description' => 'Optimalisatie van werkprocessen en efficiëntie', 'is_active' => 1],
['name' => 'Klant & Service', 'description' => 'Klanttevredenheid, support en klantervaring', 'is_active' => 1],
['name' => 'Technologie & IT', 'description' => 'Architectuur, tooling, infrastructuur en tech stack','is_active' => 1],
['name' => 'Marketing & Groei', 'description' => 'Leadgeneratie, branding en marktpositie', 'is_active' => 1],
];
$saved = get_system_setting('brainstorm_categories', null);
if (is_array($saved) && !empty($saved)) {
return $saved;
}
return $defaultCategories;
}
/**
* Registreert AI verbruiksstatistieken (aanroepen, tokens, provider/model)
*/
function record_ai_usage(string $provider, string $model, int $promptTokens = 0, int $completionTokens = 0, ?int $userId = null): void {
try {
$stats = get_system_setting('ai_usage_stats', [
'total_requests' => 0,
'total_prompt_tokens' => 0,
'total_completion_tokens' => 0,
'total_tokens' => 0,
'last_used_at' => null,
'by_provider' => [],
'by_model' => [],
'by_date' => [],
]);
$stats['total_requests'] = (int)($stats['total_requests'] ?? 0) + 1;
$stats['total_prompt_tokens'] = (int)($stats['total_prompt_tokens'] ?? 0) + $promptTokens;
$stats['total_completion_tokens'] = (int)($stats['total_completion_tokens'] ?? 0) + $completionTokens;
$stats['total_tokens'] = (int)($stats['total_tokens'] ?? 0) + ($promptTokens + $completionTokens);
$stats['last_used_at'] = date('Y-m-d H:i:s');
// Per provider
if (!isset($stats['by_provider'][$provider])) {
$stats['by_provider'][$provider] = 0;
}
$stats['by_provider'][$provider]++;
// Per model
if (!isset($stats['by_model'][$model])) {
$stats['by_model'][$model] = 0;
}
$stats['by_model'][$model]++;
// Per datum
$today = date('Y-m-d');
if (!isset($stats['by_date'][$today])) {
$stats['by_date'][$today] = ['requests' => 0, 'tokens' => 0];
}
$stats['by_date'][$today]['requests']++;
$stats['by_date'][$today]['tokens'] += ($promptTokens + $completionTokens);
// Maximaal 30 dagen historiek
if (count($stats['by_date']) > 30) {
ksort($stats['by_date']);
$stats['by_date'] = array_slice($stats['by_date'], -30, 30, true);
}
set_system_setting('ai_usage_stats', $stats);
} catch (Throwable $e) {
error_log("record_ai_usage fout: " . $e->getMessage());
}
}
/**
* Haalt de AI verbruiksstatistieken op
*/
function get_ai_usage_stats(): array {
$stats = get_system_setting('ai_usage_stats', null);
if (!is_array($stats)) {
return [
'total_requests' => 0,
'total_prompt_tokens' => 0,
'total_completion_tokens' => 0,
'total_tokens' => 0,
'last_used_at' => null,
'by_provider' => [],
'by_model' => [],
'by_date' => [],
];
}
return $stats;
}
+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);
}
}
+2 -1
View File
@@ -11,7 +11,8 @@ if (!has_admin_user()) {
exit;
}
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
$user = get_logged_in_user();
if ($user) {
header('Location: /planner.php');
exit;
}
+11 -693
View File
@@ -1,701 +1,19 @@
<?php
/**
* Fociz Integraties Hub (Microsoft Outlook, Microsoft Teams, Atlassian Jira)
* Fociz Integraties Doorverwijzer
* Beheerders worden automatisch doorgeleid naar Systeembeheer (/admin.php).
* Collega's worden automatisch doorgeleid naar hun Persoonlijk Profiel (/profile.php).
*/
require_once __DIR__ . '/includes/header.php';
require_once __DIR__ . '/includes/integrations/microsoft.php';
require_once __DIR__ . '/includes/integrations/jira.php';
require_once __DIR__ . '/includes/auth.php';
$db = get_db_connection();
$userId = $currentUser['id'];
$user = require_auth();
$message = null;
$messageType = 'success';
$queryString = !empty($_SERVER['QUERY_STRING']) ? '?' . $_SERVER['QUERY_STRING'] : '';
// Query parameters (bijv. na OAuth redirect)
if (!empty($_GET['success'])) {
$message = $_GET['success'];
$messageType = 'success';
} elseif (!empty($_GET['error'])) {
$message = $_GET['error'];
$messageType = 'error';
if ($user['role'] === 'ADMIN') {
header('Location: /admin.php' . $queryString);
} else {
header('Location: /profile.php' . $queryString);
}
// POST Verwerking
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$provider = strtoupper(trim($_POST['provider'] ?? ''));
$action = $_POST['action'] ?? 'save';
// 1. Ontkoppelen
if ($action === 'disconnect') {
if ($provider === 'OUTLOOK') {
microsoft_disconnect($userId);
$message = "Microsoft Outlook is succesvol ontkoppeld.";
} elseif ($provider === 'JIRA') {
jira_disconnect($userId);
$message = "Atlassian Jira is succesvol ontkoppeld.";
}
}
// 2. Outlook Opslaan
elseif ($provider === 'OUTLOOK') {
$clientId = trim($_POST['client_id'] ?? '');
$clientSecret = trim($_POST['client_secret'] ?? '');
$tenantId = trim($_POST['tenant_id'] ?? 'common');
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
// Haal bestaande tokens op
$stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmtExisting->execute([$userId]);
$existing = $stmtExisting->fetch();
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
// Als geheim veld leeg is gelaten, behoud het bestaande geheim
if (empty($clientSecret) && !empty($existingSettings['client_secret'])) {
$clientSecret = $existingSettings['client_secret'];
}
$newSettings = array_merge($existingSettings, [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'tenant_id' => $tenantId,
]);
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
VALUES (?, 'OUTLOOK', 1, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
is_demo_mode = VALUES(is_demo_mode),
settings = VALUES(settings),
last_sync_at = NOW()
");
$stmt->execute([$userId, $isDemo, json_encode($newSettings)]);
$message = "Instellingen voor Microsoft Outlook zijn opgeslagen.";
// Als gebruiker direct op 'Koppel nu' klikte
if ($action === 'connect_oauth') {
header("Location: /auth/oauth_microsoft.php?client_id=" . urlencode($clientId) . "&tenant_id=" . urlencode($tenantId));
exit;
}
}
// 3. Teams Opslaan
elseif ($provider === 'TEAMS') {
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, last_sync_at)
VALUES (?, 'TEAMS', 1, ?, NOW())
ON DUPLICATE KEY UPDATE is_demo_mode = VALUES(is_demo_mode), last_sync_at = NOW()
");
$stmt->execute([$userId, $isDemo]);
$message = "Instellingen voor Microsoft Teams zijn bijgewerkt.";
}
// 4. Jira Opslaan & Testen
elseif ($provider === 'JIRA') {
$jiraDomain = jira_normalize_domain($_POST['jira_domain'] ?? '');
$jiraEmail = trim($_POST['jira_email'] ?? '');
$jiraToken = trim($_POST['jira_api_token'] ?? '');
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
// Haal bestaande instellingen op
$stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
$stmtExisting->execute([$userId]);
$existing = $stmtExisting->fetch();
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
if (empty($jiraToken) && !empty($existingSettings['jira_api_token'])) {
$jiraToken = $existingSettings['jira_api_token'];
}
// Als niet in demo mode, test verbinding
$accountName = $existingSettings['account_name'] ?? '';
$accountId = $existingSettings['account_id'] ?? '';
if (!$isDemo && !empty($jiraDomain) && !empty($jiraEmail) && !empty($jiraToken)) {
$testRes = jira_test_connection($jiraDomain, $jiraEmail, $jiraToken);
if (!$testRes['success']) {
$message = "Jira Verbindingsfout: " . $testRes['error'];
$messageType = 'error';
} else {
$accountName = $testRes['user']['displayName'] ?? 'Jira Gebruiker';
$accountId = $testRes['user']['accountId'] ?? '';
$message = "Verbinding met Jira geslaagd! Ingelogd als: {$accountName}";
}
} else {
$message = "Jira instellingen opgeslagen.";
}
$newSettings = [
'jira_domain' => $jiraDomain,
'jira_email' => $jiraEmail,
'jira_api_token' => $jiraToken,
'account_name' => $accountName,
'account_id' => $accountId,
];
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
VALUES (?, 'JIRA', 1, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
is_demo_mode = VALUES(is_demo_mode),
settings = VALUES(settings),
last_sync_at = NOW()
");
$stmt->execute([$userId, $isDemo, json_encode($newSettings)]);
// Wis cache
unset($_SESSION["jira_issues_user_{$userId}"]);
}
}
// Haal huidige configuraties op
$stmtConfigs = $db->prepare("SELECT * FROM integrations WHERE user_id = ?");
$stmtConfigs->execute([$userId]);
$configsRaw = $stmtConfigs->fetchAll();
$configs = [];
foreach ($configsRaw as $c) {
$configs[$c['provider']] = $c;
$configs[$c['provider']]['settings_parsed'] = json_decode($c['settings'] ?? '{}', true) ?: [];
}
// Bepaal statussen
$isOutlookConnected = !empty($configs['OUTLOOK']['access_token']) && empty($configs['OUTLOOK']['is_demo_mode']);
$outlookSettings = $configs['OUTLOOK']['settings_parsed'] ?? [];
$isJiraConnected = !empty($configs['JIRA']) && empty($configs['JIRA']['is_demo_mode']) && !empty($configs['JIRA']['settings_parsed']['jira_api_token']);
$jiraSettings = $configs['JIRA']['settings_parsed'] ?? [];
// Haal actuele Jira issues op
$forceRefresh = isset($_GET['refresh_jira']);
$jiraData = jira_get_user_issues($userId, $forceRefresh);
$jiraIssues = $jiraData['issues'] ?? [];
$jiraIsDemo = !empty($jiraData['is_demo']);
// Callback redirect URI
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
?>
<div class="space-y-6">
<!-- Header -->
<div class="bg-gradient-to-r from-slate-900 via-[#0d1527] to-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div class="flex items-center space-x-2 text-blue-400 text-xs font-semibold uppercase tracking-wider mb-1">
<i data-lucide="layers" class="w-4 h-4"></i>
<span>Live Koppelingen & Data Synchronisatie</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
Integraties Hub
</h1>
<p class="text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
Koppel Microsoft 365 Outlook (voor realtime agenda & Teams meetings) en Atlassian Jira Cloud (om direct uw toegewezen tickets in te plannen).
</p>
</div>
<!-- Snelle status pills -->
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl border text-xs font-semibold <?= $isOutlookConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-slate-800/80 border-slate-700 text-slate-400' ?>">
<span class="w-2 h-2 rounded-full <?= $isOutlookConnected ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500' ?>"></span>
<span>Outlook: <?= $isOutlookConnected ? 'Verbonden' : 'Niet verbonden' ?></span>
</div>
<div class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl border text-xs font-semibold <?= $isJiraConnected ? 'bg-sky-500/10 border-sky-500/30 text-sky-400' : 'bg-slate-800/80 border-slate-700 text-slate-400' ?>">
<span class="w-2 h-2 rounded-full <?= $isJiraConnected ? 'bg-sky-400 animate-pulse' : 'bg-slate-500' ?>"></span>
<span>Jira: <?= $isJiraConnected ? 'Verbonden' : 'Niet verbonden' ?></span>
</div>
</div>
</div>
</div>
<!-- Flash message -->
<?php if ($message): ?>
<div class="p-4 rounded-xl text-xs font-semibold flex items-center justify-between border <?= $messageType === 'error' ? 'bg-red-500/10 border-red-500/30 text-red-400' : 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' ?>">
<div class="flex items-center space-x-2">
<i data-lucide="<?= $messageType === 'error' ? 'alert-triangle' : 'check-circle' ?>" class="w-4 h-4"></i>
<span><?= htmlspecialchars($message) ?></span>
</div>
<button onclick="this.parentElement.remove()" class="text-slate-500 hover:text-slate-300">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<?php endif; ?>
<!-- 3 Koloms Grid voor Providers -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 1. MICROSOFT OUTLOOK -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between relative overflow-hidden backdrop-blur-sm">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-400 flex items-center justify-center font-bold">
<i data-lucide="calendar" class="w-5 h-5"></i>
</div>
<?php if ($isOutlookConnected): ?>
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold flex items-center space-x-1">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span>Live Verbonden</span>
</span>
<?php elseif (!empty($configs['OUTLOOK']['is_demo_mode'])): ?>
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-semibold">
Sandbox Modus
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-400 text-xs font-semibold">
Niet Gekoppeld
</span>
<?php endif; ?>
</div>
<div>
<h3 class="text-base font-bold text-white mb-1">Microsoft Outlook (365)</h3>
<p class="text-xs text-slate-400 leading-relaxed">
Synchroniseert automatisch uw zakelijke agenda-afspraken en meetings naar de dagplanner tijdlijn.
</p>
</div>
<?php if ($isOutlookConnected): ?>
<div class="bg-slate-950/70 rounded-xl p-3.5 border border-emerald-500/20 text-xs space-y-2">
<div class="flex items-center justify-between text-slate-400">
<span>Account:</span>
<span class="text-emerald-400 font-semibold truncate max-w-[180px]" title="<?= htmlspecialchars($outlookSettings['account_email'] ?? '') ?>">
<?= htmlspecialchars($outlookSettings['account_email'] ?? 'Verbonden') ?>
</span>
</div>
<div class="flex items-center justify-between text-slate-400">
<span>Naam:</span>
<span class="text-slate-200"><?= htmlspecialchars($outlookSettings['account_name'] ?? '-') ?></span>
</div>
<div class="flex items-center justify-between text-slate-400">
<span>Laatste Sync:</span>
<span class="text-slate-300 font-mono text-[11px]"><?= htmlspecialchars($configs['OUTLOOK']['last_sync_at'] ?? 'Zojuist') ?></span>
</div>
</div>
<?php endif; ?>
<!-- Azure App Instructies Accordion -->
<details class="group text-xs bg-slate-950/50 rounded-xl border border-slate-800/80 p-3">
<summary class="cursor-pointer font-semibold text-blue-400 flex items-center justify-between">
<span class="flex items-center space-x-1.5">
<i data-lucide="help-circle" class="w-3.5 h-3.5"></i>
<span>Azure App Registratie Instructies</span>
</span>
<i data-lucide="chevron-down" class="w-3.5 h-3.5 group-open:rotate-180 transition"></i>
</summary>
<div class="mt-3 space-y-2 text-slate-400 text-[11px] leading-relaxed border-t border-slate-800/60 pt-2">
<p>1. Open <a href="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade" target="_blank" class="text-blue-400 underline">Azure App Registraties</a> en maak een nieuwe registratie.</p>
<p>2. Kies <em>Accounts in elke organisatiemap (...) en persoonlijke Microsoft-accounts</em>.</p>
<p>3. Voeg een <strong>Web</strong> Omleidings-URI toe:</p>
<div class="flex items-center space-x-1 bg-slate-900 p-1.5 rounded border border-slate-800">
<input type="text" readonly id="msRedirectUriInput" value="<?= htmlspecialchars($redirectUri) ?>" class="bg-transparent text-[10px] text-emerald-400 font-mono flex-1 focus:outline-none">
<button onclick="copyToClipboard('msRedirectUriInput')" class="text-slate-400 hover:text-white px-1.5 py-0.5 rounded bg-slate-800 text-[10px]">Kopieer</button>
</div>
<p>4. Maak een geheim aan onder <em>Certificaten en geheimen</em>.</p>
<p>5. Voeg API-machtigingen toe: <code>Calendars.Read</code>, <code>User.Read</code>, <code>offline_access</code>.</p>
</div>
</details>
</div>
<!-- Instellingen formulier -->
<form method="POST" action="/integrations.php" class="pt-4 border-t border-slate-800 space-y-2.5 mt-4">
<input type="hidden" name="provider" value="OUTLOOK">
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Application (Client) ID</label>
<input
type="text"
name="client_id"
value="<?= htmlspecialchars($outlookSettings['client_id'] ?? fociz_env('MICROSOFT_CLIENT_ID', '')) ?>"
placeholder="e.g. 11111111-2222-3333-4444-555555555555"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 font-mono"
>
</div>
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Client Secret</label>
<div class="relative">
<input
type="password"
id="msClientSecret"
name="client_secret"
value="<?= !empty($outlookSettings['client_secret']) ? '••••••••••••••••' : htmlspecialchars(fociz_env('MICROSOFT_CLIENT_SECRET', '')) ?>"
placeholder="Client Secret Value"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 pr-8 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 font-mono"
>
<button type="button" onclick="togglePasswordVisibility('msClientSecret')" class="absolute right-2 top-2 text-slate-500 hover:text-slate-300">
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Directory (Tenant) ID</label>
<input
type="text"
name="tenant_id"
value="<?= htmlspecialchars($outlookSettings['tenant_id'] ?? fociz_env('MICROSOFT_TENANT_ID', 'common')) ?>"
placeholder="common of specifiek GUID"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 font-mono"
>
</div>
<label class="flex items-center space-x-2 text-xs text-slate-300 pt-1">
<input type="checkbox" name="is_demo_mode" value="1" <?= !empty($configs['OUTLOOK']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-blue-500">
<span>Gebruik interactieve Sandbox data</span>
</label>
<div class="flex space-x-2 pt-2">
<button type="submit" name="action" value="save" class="flex-1 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition">
Opslaan
</button>
<button type="submit" name="action" value="connect_oauth" class="flex-1 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition flex items-center justify-center space-x-1 shadow-md shadow-blue-500/20">
<i data-lucide="key" class="w-3.5 h-3.5"></i>
<span><?= $isOutlookConnected ? 'Opnieuw Koppelen' : 'Koppel Outlook' ?></span>
</button>
</div>
<?php if ($isOutlookConnected): ?>
<button type="submit" name="action" value="disconnect" onclick="return confirm('Weet u zeker dat u Microsoft Outlook wilt ontkoppelen?')" class="w-full py-1.5 text-[11px] text-red-400 hover:text-red-300 transition text-center block">
Koppeling Verwijderen
</button>
<?php endif; ?>
</form>
</div>
<!-- 2. MICROSOFT TEAMS -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between relative overflow-hidden backdrop-blur-sm">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-xl bg-indigo-500/10 border border-indigo-500/30 text-indigo-400 flex items-center justify-center font-bold">
<i data-lucide="video" class="w-5 h-5"></i>
</div>
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium flex items-center space-x-1">
<i data-lucide="check-circle" class="w-3 h-3"></i>
<span>Inbegrepen bij Outlook</span>
</span>
</div>
<div>
<h3 class="text-base font-bold text-white mb-1">Microsoft Teams</h3>
<p class="text-xs text-slate-400 leading-relaxed">
Detecteert Teams vergaderingen automatisch vanuit uw Outlook agenda en toont directe 'Deelnemen' knoppen.
</p>
</div>
<div class="bg-slate-950/60 rounded-xl p-3.5 border border-slate-800/80 text-xs space-y-2">
<div class="flex items-center justify-between text-slate-400">
<span>Direct Deelnemen:</span>
<span class="text-emerald-400 font-semibold">Ondersteund</span>
</div>
<div class="flex items-center justify-between text-slate-400">
<span>Meeting detectie:</span>
<span class="text-slate-200">Via Graph API</span>
</div>
<div class="flex items-center justify-between text-slate-400">
<span>Status:</span>
<span class="text-indigo-400 font-semibold"><?= $isOutlookConnected ? 'Actief' : 'Koppel Outlook' ?></span>
</div>
</div>
<div class="p-3 bg-indigo-950/30 border border-indigo-900/50 rounded-xl text-xs text-indigo-200/80">
<p class="leading-relaxed">
Teams vergaderingen worden automatisch uitgelezen zodra Microsoft Outlook is gekoppeld. U hoeft voor Teams geen aparte koppeling te configureren!
</p>
</div>
</div>
<form method="POST" action="/integrations.php" class="pt-4 border-t border-slate-800 space-y-3 mt-4">
<input type="hidden" name="provider" value="TEAMS">
<input type="hidden" name="action" value="save">
<label class="flex items-center space-x-2 text-xs text-slate-300">
<input type="checkbox" name="is_demo_mode" value="1" <?= empty($configs['TEAMS']) || !empty($configs['TEAMS']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-indigo-500">
<span>Gebruik interactieve Sandbox data</span>
</label>
<button type="submit" class="w-full py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-white text-xs font-semibold transition">
Teams Instellingen Opslaan
</button>
</form>
</div>
<!-- 3. ATLASSIAN JIRA -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between relative overflow-hidden backdrop-blur-sm">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-xl bg-sky-500/10 border border-sky-500/30 text-sky-400 flex items-center justify-center font-bold">
<i data-lucide="trello" class="w-5 h-5"></i>
</div>
<?php if ($isJiraConnected): ?>
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold flex items-center space-x-1">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span>Live Verbonden</span>
</span>
<?php elseif (!empty($configs['JIRA']['is_demo_mode'])): ?>
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-semibold">
Sandbox Modus
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-400 text-xs font-semibold">
Niet Gekoppeld
</span>
<?php endif; ?>
</div>
<div>
<h3 class="text-base font-bold text-white mb-1">Atlassian Jira Cloud</h3>
<p class="text-xs text-slate-400 leading-relaxed">
Koppelt uw Jira tickets rechtstreeks aan de Fociz dagplanning en roadmap.
</p>
</div>
<?php if ($isJiraConnected): ?>
<div class="bg-slate-950/70 rounded-xl p-3.5 border border-sky-500/20 text-xs space-y-2">
<div class="flex items-center justify-between text-slate-400">
<span>Ingelogd als:</span>
<span class="text-sky-400 font-semibold truncate max-w-[170px]" title="<?= htmlspecialchars($jiraSettings['jira_email'] ?? '') ?>">
<?= htmlspecialchars($jiraSettings['account_name'] ?: ($jiraSettings['jira_email'] ?? '')) ?>
</span>
</div>
<div class="flex items-center justify-between text-slate-400">
<span>Domein:</span>
<span class="text-slate-200 font-mono text-[11px] truncate max-w-[170px]"><?= htmlspecialchars($jiraSettings['jira_domain'] ?? '-') ?></span>
</div>
<div class="flex items-center justify-between text-slate-400">
<span>Tickets Toegewezen:</span>
<span class="text-emerald-400 font-bold"><?= count($jiraIssues) ?> open taken</span>
</div>
</div>
<?php endif; ?>
<div class="bg-slate-950/50 rounded-xl border border-slate-800/80 p-3 text-xs text-slate-400">
<div class="flex items-center justify-between">
<span class="text-slate-300 font-semibold">Atlassian API Token nodig?</span>
<a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" class="text-sky-400 hover:text-sky-300 font-semibold flex items-center space-x-1">
<span>Genereer Token</span>
<i data-lucide="external-link" class="w-3 h-3"></i>
</a>
</div>
</div>
</div>
<!-- Jira Formulier -->
<form method="POST" action="/integrations.php" class="pt-4 border-t border-slate-800 space-y-2.5 mt-4">
<input type="hidden" name="provider" value="JIRA">
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Jira Cloud Domein</label>
<input
type="text"
name="jira_domain"
value="<?= htmlspecialchars($jiraSettings['jira_domain'] ?? fociz_env('JIRA_DOMAIN', '')) ?>"
placeholder="uwbedrijf.atlassian.net"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-sky-500 font-mono"
>
</div>
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Atlassian E-mailadres</label>
<input
type="email"
name="jira_email"
value="<?= htmlspecialchars($jiraSettings['jira_email'] ?? fociz_env('JIRA_EMAIL', '')) ?>"
placeholder="collega@uwbedrijf.nl"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-sky-500"
>
</div>
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Atlassian API Token</label>
<div class="relative">
<input
type="password"
id="jiraApiTokenInput"
name="jira_api_token"
value="<?= !empty($jiraSettings['jira_api_token']) ? '••••••••••••••••' : htmlspecialchars(fociz_env('JIRA_API_TOKEN', '')) ?>"
placeholder="Plak hier uw Atlassian API token"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 pr-8 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-sky-500 font-mono"
>
<button type="button" onclick="togglePasswordVisibility('jiraApiTokenInput')" class="absolute right-2 top-2 text-slate-500 hover:text-slate-300">
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
<label class="flex items-center space-x-2 text-xs text-slate-300 pt-1">
<input type="checkbox" name="is_demo_mode" value="1" <?= empty($configs['JIRA']) || !empty($configs['JIRA']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-sky-500">
<span>Gebruik interactieve Sandbox data</span>
</label>
<div class="flex space-x-2 pt-2">
<button type="submit" name="action" value="save" class="w-full py-2 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-xs font-bold transition flex items-center justify-center space-x-1.5 shadow-md shadow-sky-500/20">
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
<span>Verbinding Testen & Opslaan</span>
</button>
</div>
<?php if ($isJiraConnected): ?>
<button type="submit" name="action" value="disconnect" onclick="return confirm('Weet u zeker dat u Atlassian Jira wilt ontkoppelen?')" class="w-full py-1.5 text-[11px] text-red-400 hover:text-red-300 transition text-center block">
Koppeling Verwijderen
</button>
<?php endif; ?>
</form>
</div>
</div>
<!-- Gesynchroniseerde Jira Issues & 1-Click Import naar Dagplanning -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-slate-800">
<div class="flex items-center space-x-2">
<i data-lucide="check-square" class="w-5 h-5 text-sky-400"></i>
<h3 class="text-sm font-bold text-white uppercase tracking-wider">
Jira Issues Toegewezen aan U
</h3>
<?php if ($jiraIsDemo): ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">Sandbox</span>
<?php else: ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">Live Jira</span>
<?php endif; ?>
</div>
<div class="flex items-center space-x-3">
<span class="text-xs text-slate-400 font-mono"><?= count($jiraIssues) ?> issues</span>
<a href="/integrations.php?refresh_jira=1" class="flex items-center space-x-1 text-xs text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 px-2.5 py-1 rounded-lg border border-slate-700 transition">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
<span>Verversen</span>
</a>
</div>
</div>
<?php if (empty($jiraIssues)): ?>
<div class="text-center py-10 text-slate-500 text-xs">
Geen openstaande tickets gevonden die aan u zijn toegewezen in Jira.
</div>
<?php else: ?>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<?php foreach ($jiraIssues as $issue):
$priority = $issue['priority'] ?? 'Medium';
$priorityClass = match(strtolower($priority)) {
'highest', 'high', 'hoog' => 'text-red-400 bg-red-500/10 border-red-500/20',
'medium', 'gemiddeld' => 'text-amber-400 bg-amber-500/10 border-amber-500/20',
default => 'text-slate-400 bg-slate-800/60 border-slate-700',
};
?>
<div class="bg-slate-950/70 border border-slate-800/80 hover:border-slate-700 rounded-xl p-4 flex flex-col justify-between transition group">
<div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="font-mono text-xs font-bold text-sky-400 bg-sky-500/10 border border-sky-500/20 px-2 py-0.5 rounded">
<?= htmlspecialchars($issue['key']) ?>
</span>
<span class="text-[10px] text-slate-400 font-medium truncate max-w-[140px]">
<?= htmlspecialchars($issue['project'] ?? '') ?>
</span>
</div>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded bg-slate-800 text-slate-300">
<?= htmlspecialchars($issue['status']) ?>
</span>
</div>
<h4 class="text-xs font-bold text-white leading-snug group-hover:text-sky-300 transition">
<?= htmlspecialchars($issue['summary']) ?>
</h4>
<div class="flex items-center space-x-3 mt-2 text-[10px] text-slate-500">
<span>Type: <?= htmlspecialchars($issue['issue_type'] ?? 'Issue') ?></span>
<?php if (!empty($issue['due_date'])): ?>
<span>Vervaldatum: <?= htmlspecialchars($issue['due_date']) ?></span>
<?php endif; ?>
</div>
</div>
<div class="mt-4 pt-3 border-t border-slate-800/80 flex items-center justify-between">
<span class="text-[10px] font-bold px-2 py-0.5 rounded border <?= $priorityClass ?>">
<?= htmlspecialchars($priority) ?>
</span>
<div class="flex items-center space-x-2">
<?php if (!empty($issue['url']) && $issue['url'] !== '#'): ?>
<a href="<?= htmlspecialchars($issue['url']) ?>" target="_blank" class="p-1 rounded-lg text-slate-500 hover:text-sky-400 transition" title="Open in Jira">
<i data-lucide="external-link" class="w-3.5 h-3.5"></i>
</a>
<?php endif; ?>
<button
onclick="importJiraToTasks(this, '<?= htmlspecialchars(addslashes($issue['summary'])) ?>', '<?= htmlspecialchars($issue['key']) ?>', '<?= htmlspecialchars(addslashes($issue['url'] ?? '')) ?>', 'P1')"
class="flex items-center space-x-1 text-[11px] font-semibold text-emerald-400 hover:text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 px-2.5 py-1 rounded-lg transition"
>
<i data-lucide="plus" class="w-3 h-3"></i>
<span>Plan als P1</span>
</button>
<button
onclick="importJiraToTasks(this, '<?= htmlspecialchars(addslashes($issue['summary'])) ?>', '<?= htmlspecialchars($issue['key']) ?>', '<?= htmlspecialchars(addslashes($issue['url'] ?? '')) ?>', 'P2')"
class="flex items-center space-x-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 px-2.5 py-1 rounded-lg transition"
>
<span>Plan P2</span>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<script>
function copyToClipboard(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
navigator.clipboard.writeText(el.value).then(() => {
alert('Omleidings-URI gekopieerd naar klembord!');
}).catch(() => {
el.select();
document.execCommand('copy');
alert('Omleidings-URI gekopieerd!');
});
}
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password';
}
async function importJiraToTasks(btn, title, jiraKey, url, priority = 'P1') {
const originalHtml = btn.innerHTML;
btn.innerText = 'Toevoegen...';
btn.disabled = true;
try {
const res = await fetch('/api/planner.php?action=add_task', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
title: `[${jiraKey}] ${title}`,
priority: priority,
source: 'JIRA',
external_id: jiraKey,
external_url: url,
date: '<?= date('Y-m-d') ?>'
})
});
const data = await res.json();
if (data.success) {
btn.innerHTML = '<i data-lucide="check" class="w-3 h-3"></i><span>Gepland!</span>';
btn.className = 'flex items-center space-x-1 text-[11px] font-semibold text-emerald-400 bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg';
if (typeof lucide !== 'undefined') lucide.createIcons();
} else {
btn.innerHTML = originalHtml;
btn.disabled = false;
alert(data.error || 'Kon taak niet toevoegen');
}
} catch(err) {
btn.innerHTML = originalHtml;
btn.disabled = false;
alert('Netwerkfout bij toevoegen van taak');
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
exit;
+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
+8178 -460
View File
File diff suppressed because it is too large Load Diff
+1550
View File
File diff suppressed because it is too large Load Diff
+712 -68
View File
@@ -27,45 +27,73 @@ if ($selectedStatus !== 'ALL') {
$params[] = $selectedStatus;
}
$sql .= " ORDER BY r.quarter ASC, r.priority DESC, r.due_date ASC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$roadmapItems = $stmt->fetchAll();
$roadmapItems = [];
try {
$stmt = $db->prepare($sql);
$stmt->execute($params);
$roadmapItems = $stmt->fetchAll() ?: [];
} catch (Throwable $e) {
error_log("Fout bij ophalen roadmap items: " . $e->getMessage());
ensure_tables_exist($db);
}
$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">
<div class="space-y-6 max-w-7xl mx-auto">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
<div class="fociz-card p-6 sm:p-7 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div class="flex items-center space-x-2 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-1">
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Planning & Strategie</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
Roadmap & Tijdlijn
</h1>
<p class="text-slate-400 text-xs sm:text-sm mt-1">
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
Centraal overzicht van alle mijlpalen, features en doelen, direct voortgekomen uit brainstorms en gesynchroniseerd met Jira.
</p>
</div>
<button onclick="document.getElementById('newItemModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-emerald-500 hover:bg-emerald-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-500/20 flex-shrink-0">
<button onclick="document.getElementById('newItemModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs flex-shrink-0 cursor-pointer">
<i data-lucide="plus" class="w-4 h-4"></i>
<span>Nieuw Item</span>
</button>
</div>
<!-- Filters: Quarters & Status -->
<div class="flex flex-wrap items-center justify-between gap-3 bg-slate-900/40 border border-slate-800/80 rounded-2xl p-4">
<!-- Quarter Tabs -->
<div class="fociz-card p-4 flex flex-wrap items-center justify-between gap-3">
<!-- 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-xl transition font-medium <?= $selectedQuarter === 'ALL' ? 'bg-emerald-500 text-slate-950 font-bold' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800' ?>">
<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-xl transition font-medium <?= $selectedQuarter === $q ? 'bg-emerald-500 text-slate-950 font-bold' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800' ?>">
<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; ?>
@@ -73,8 +101,8 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<!-- Status Filter -->
<div class="flex items-center space-x-2 text-xs">
<span class="text-slate-500">Status:</span>
<select onchange="window.location.href='/roadmap.php?quarter=<?= htmlspecialchars($selectedQuarter) ?>&status='+this.value" class="bg-slate-800 border border-slate-700 rounded-xl px-2.5 py-1 text-white text-xs focus:outline-none">
<span class="text-slate-500 dark:text-slate-400 font-semibold">Status:</span>
<select onchange="window.location.href='/roadmap.php?quarter=<?= htmlspecialchars($selectedQuarter) ?>&status='+this.value" class="bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-semibold focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="ALL" <?= $selectedStatus === 'ALL' ? 'selected' : '' ?>>Alle Statussen</option>
<option value="PLANNED" <?= $selectedStatus === 'PLANNED' ? 'selected' : '' ?>>Gepland</option>
<option value="IN_PROGRESS" <?= $selectedStatus === 'IN_PROGRESS' ? 'selected' : '' ?>>In Uitvoering</option>
@@ -87,38 +115,61 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<!-- Roadmap Items Lijst -->
<div class="space-y-4">
<?php if (empty($roadmapItems)): ?>
<div class="bg-slate-900/60 border border-slate-800/80 rounded-2xl p-12 text-center">
<i data-lucide="map-pin" class="w-12 h-12 text-emerald-500/40 mx-auto mb-3"></i>
<h3 class="text-base font-bold text-white">Geen items gevonden op de roadmap</h3>
<p class="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
Ga naar een <a href="/brainstorm.php" class="text-amber-400 underline">Brainstorm sessie</a> en zet ideeën met één klik om naar de roadmap, of voeg handmatig een item toe.
<div class="fociz-card p-12 text-center">
<i data-lucide="map-pin" class="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3"></i>
<h3 class="text-base font-bold text-slate-900 dark:text-white">Geen items gevonden op de roadmap</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-sm mx-auto">
Ga naar een <a href="/brainstorm.php" class="text-indigo-600 dark:text-indigo-400 underline font-medium">Brainstorm sessie</a> en zet ideeën met één klik om naar de roadmap, of voeg handmatig een item toe.
</p>
</div>
<?php else: ?>
<?php foreach ($roadmapItems as $item): ?>
<div class="bg-slate-900/80 border border-slate-800 hover:border-slate-700 rounded-2xl p-5 shadow-lg transition space-y-3" id="roadmap-item-<?= $item['id'] ?>">
<?php foreach ($roadmapItems as $item):
$statusBorder = match($item['status']) {
'COMPLETED' => 'border-l-4 border-l-emerald-500',
'IN_PROGRESS' => 'border-l-4 border-l-indigo-500',
'REVIEW' => 'border-l-4 border-l-amber-500',
default => 'border-l-4 border-l-slate-300 dark:border-l-slate-700',
};
?>
<div
id="roadmap-item-<?= $item['id'] ?>"
draggable="true"
ondragstart="handleRoadmapItemDragStart(event, <?= $item['id'] ?>)"
ondragend="handleRoadmapItemDragEnd(event)"
oncontextmenu="openRoadmapContextMenu(event, <?= $item['id'] ?>); return false;"
data-title="<?= htmlspecialchars($item['title'], ENT_QUOTES) ?>"
data-description="<?= htmlspecialchars($item['description'] ?? '', ENT_QUOTES) ?>"
data-quarter="<?= htmlspecialchars($item['quarter']) ?>"
data-type="<?= htmlspecialchars($item['type']) ?>"
data-priority="<?= htmlspecialchars($item['priority']) ?>"
data-status="<?= htmlspecialchars($item['status']) ?>"
data-due-date="<?= htmlspecialchars($item['due_date'] ?? '') ?>"
data-jira-key="<?= htmlspecialchars($item['jira_issue_key'] ?? '') ?>"
class="fociz-card-interactive p-5 space-y-3.5 <?= $statusBorder ?> cursor-grab active:cursor-grabbing transition-all duration-150"
title="Sleep naar een ander kwartaal (Q1, Q2, Q3, Q4) of klik met rechtermuisknop"
>
<!-- Top header van item -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div class="flex items-center space-x-2.5 flex-wrap">
<!-- Quarter badge -->
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 font-mono text-xs font-bold border border-emerald-500/30">
<span class="px-2.5 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 font-mono text-xs font-bold border border-indigo-200 dark:border-indigo-500/20">
<?= htmlspecialchars($item['quarter']) ?>
</span>
<!-- Type badge -->
<span class="px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 text-[10px] font-semibold">
<span class="px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-[10px] font-semibold border border-slate-200 dark:border-slate-700">
<?= htmlspecialchars($item['type']) ?>
</span>
<!-- Priority badge -->
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full <?= $item['priority'] === 'CRITICAL' ? 'bg-red-500/20 text-red-400' : ($item['priority'] === 'HIGH' ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-800 text-slate-400') ?>">
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full <?= $item['priority'] === 'CRITICAL' ? 'bg-rose-50 dark:bg-rose-500/10 text-rose-700 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20' : ($item['priority'] === 'HIGH' ? 'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20' : 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700') ?>">
<?= htmlspecialchars($item['priority']) ?>
</span>
<!-- Jira Issue Key link -->
<?php if (!empty($item['jira_issue_key'])): ?>
<a href="/integrations.php" class="flex items-center space-x-1 px-2 py-0.5 rounded-full bg-blue-950/60 border border-blue-800/60 text-blue-300 text-[10px] font-mono hover:underline">
<a href="/profile.php" class="flex items-center space-x-1 px-2.5 py-0.5 rounded-lg bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 text-sky-700 dark:text-sky-400 text-[10px] font-mono hover:underline">
<i data-lucide="layers" class="w-3 h-3"></i>
<span><?= htmlspecialchars($item['jira_issue_key']) ?></span>
</a>
@@ -127,14 +178,14 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<!-- Status Selector -->
<div class="flex items-center space-x-2">
<select onchange="updateRoadmapStatus(<?= $item['id'] ?>, this.value)" class="bg-slate-950 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-slate-200 font-semibold focus:outline-none">
<select onchange="updateRoadmapStatus(<?= $item['id'] ?>, this.value)" class="bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1 text-xs text-slate-800 dark:text-slate-200 font-bold focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="PLANNED" <?= $item['status'] === 'PLANNED' ? 'selected' : '' ?>>⏳ Gepland</option>
<option value="IN_PROGRESS" <?= $item['status'] === 'IN_PROGRESS' ? 'selected' : '' ?>>⚡ In Uitvoering</option>
<option value="REVIEW" <?= $item['status'] === 'REVIEW' ? 'selected' : '' ?>>🔍 In Review</option>
<option value="COMPLETED" <?= $item['status'] === 'COMPLETED' ? 'selected' : '' ?>>✅ Voltooid</option>
</select>
<button onclick="deleteRoadmapItem(<?= $item['id'] ?>)" title="Verwijderen" class="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition">
<button onclick="deleteRoadmapItem(<?= $item['id'] ?>)" title="Verwijderen" class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:text-slate-500 dark:hover:text-rose-400 hover:bg-rose-500/10 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
@@ -142,11 +193,11 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<!-- Titel & Beschrijving -->
<div>
<h3 class="text-base font-bold text-white leading-snug">
<h3 class="text-base font-bold text-slate-900 dark:text-white leading-snug">
<?= htmlspecialchars($item['title']) ?>
</h3>
<?php if (!empty($item['description'])): ?>
<p class="text-slate-400 text-xs mt-1 whitespace-pre-wrap">
<p class="text-slate-600 dark:text-slate-400 text-xs mt-1 whitespace-pre-wrap leading-relaxed">
<?= htmlspecialchars($item['description']) ?>
</p>
<?php endif; ?>
@@ -154,33 +205,33 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<!-- Origin Brainstorm snippet -->
<?php if (!empty($item['origin_brainstorm_card_id'])): ?>
<div class="bg-slate-950/50 rounded-xl p-2.5 text-[11px] text-slate-400 flex items-center space-x-2 border border-slate-800">
<i data-lucide="lightbulb" class="w-3.5 h-3.5 text-amber-400 flex-shrink-0"></i>
<div class="bg-slate-50/70 dark:bg-[#0c0f17]/60 rounded-xl p-2.5 text-[11px] text-slate-600 dark:text-slate-400 flex items-center space-x-2 border border-slate-200/80 dark:border-slate-800">
<i data-lucide="lightbulb" class="w-3.5 h-3.5 text-amber-500 flex-shrink-0"></i>
<span class="truncate">Ontstaan uit brainstormsessie</span>
</div>
<?php endif; ?>
<!-- Voortgangsbalk & Deadline -->
<div class="pt-2 border-t border-slate-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
<div class="pt-2.5 border-t border-slate-100 dark:border-slate-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
<!-- Progress Slider -->
<div class="flex items-center space-x-3 flex-1 max-w-md">
<span class="text-slate-400 text-[11px] font-medium w-16">Voortgang:</span>
<div class="flex-1 bg-slate-950 h-2 rounded-full overflow-hidden border border-slate-800">
<div class="bg-gradient-to-r from-emerald-500 to-teal-400 h-full transition-all" style="width: <?= $item['progress'] ?>%"></div>
<span class="text-slate-500 dark:text-slate-400 text-[11px] font-semibold w-16">Voortgang:</span>
<div class="flex-1 bg-slate-100 dark:bg-slate-900 h-2.5 rounded-full overflow-hidden border border-slate-200 dark:border-slate-800">
<div class="bg-gradient-to-r from-indigo-600 to-sky-500 h-full transition-all" style="width: <?= $item['progress'] ?>%"></div>
</div>
<span class="font-mono text-slate-200 font-bold w-10 text-right"><?= $item['progress'] ?>%</span>
<span class="font-mono text-slate-800 dark:text-slate-200 font-bold w-10 text-right"><?= $item['progress'] ?>%</span>
<div class="flex space-x-1">
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 25)" class="px-1.5 py-0.5 rounded bg-slate-800 text-[10px] text-slate-400 hover:text-white">25%</button>
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 50)" class="px-1.5 py-0.5 rounded bg-slate-800 text-[10px] text-slate-400 hover:text-white">50%</button>
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 100)" class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-[10px] text-emerald-400 hover:text-emerald-300 font-bold">100%</button>
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 25)" class="px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white cursor-pointer font-semibold transition">25%</button>
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 50)" class="px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white cursor-pointer font-semibold transition">50%</button>
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 100)" class="px-2 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/15 text-[10px] text-indigo-700 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-bold cursor-pointer transition">100%</button>
</div>
</div>
<!-- Deadline & Creator -->
<div class="flex items-center space-x-3 text-slate-500 text-[11px]">
<?php if (!empty($item['due_date'])): ?>
<span class="flex items-center space-x-1 text-slate-300">
<i data-lucide="calendar" class="w-3.5 h-3.5 text-emerald-400"></i>
<span class="flex items-center space-x-1 text-slate-700 dark:text-slate-300 font-semibold">
<i data-lucide="calendar" class="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400"></i>
<span>Deadline: <?= date('d-m-Y', strtotime($item['due_date'])) ?></span>
</span>
<?php endif; ?>
@@ -194,33 +245,33 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
</div>
<!-- Modal: Nieuw Roadmap Item Handmatig Toevoegen -->
<div id="newItemModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
<h3 class="text-base font-bold text-white flex items-center space-x-2">
<i data-lucide="map" class="w-4 h-4 text-emerald-400"></i>
<div id="newItemModal" onclick="if(event.target === this) this.classList.add('hidden')" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="map" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Nieuw Roadmap Item Toevoegen</span>
</h3>
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="text-slate-400 hover:text-white">
<i data-lucide="x" class="w-5 h-5"></i>
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Titel / Doel</label>
<input type="text" id="manualTitle" placeholder="Bijv. 2FA Authenticatie afronden" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Titel / Doel</label>
<input type="text" id="manualTitle" placeholder="Bijv. 2FA Authenticatie afronden" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Beschrijving / Specificatie</label>
<textarea id="manualDescription" rows="3" placeholder="Wat houdt deze milestone of feature in?" class="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white text-xs placeholder-slate-500 focus:outline-none resize-none"></textarea>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beschrijving / Specificatie</label>
<textarea id="manualDescription" rows="3" placeholder="Wat houdt deze milestone of feature in?" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Kwartaal</label>
<select id="manualQuarter" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Kwartaal</label>
<select id="manualQuarter" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="Q1">Q1</option>
<option value="Q2">Q2</option>
<option value="Q3" selected>Q3</option>
@@ -228,8 +279,8 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Type</label>
<select id="manualType" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select id="manualType" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="FEATURE" selected>Feature</option>
<option value="EPIC">Epic</option>
<option value="MILESTONE">Mijlpaal</option>
@@ -240,8 +291,8 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Prioriteit</label>
<select id="manualPriority" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
<select id="manualPriority" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="CRITICAL">Kritiek</option>
<option value="HIGH" selected>Hoog</option>
<option value="MEDIUM">Normaal</option>
@@ -249,21 +300,21 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Jira Issue Key (optioneel)</label>
<input type="text" id="manualJiraKey" placeholder="Bijv. FOC-101" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-white text-xs font-mono focus:outline-none">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Jira Issue Key (optioneel)</label>
<input type="text" id="manualJiraKey" placeholder="Bijv. FOC-101" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Streefdatum</label>
<input type="date" id="manualDueDate" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-white text-xs focus:outline-none">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum</label>
<input type="date" id="manualDueDate" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div class="flex space-x-2 pt-2">
<button onclick="submitManualItem()" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition">
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<button onclick="submitManualItem()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
Toevoegen aan Roadmap
</button>
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren
</button>
</div>
@@ -271,6 +322,236 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
</div>
</div>
<!-- Modal: Roadmap Item Bewerken -->
<div id="editRoadmapModal" onclick="if(event.target === this) closeEditRoadmapModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Roadmap Item Bewerken</span>
</h3>
<button onclick="closeEditRoadmapModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<input type="hidden" id="editRoadmapId">
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Titel / Doel</label>
<input type="text" id="editRoadmapTitle" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beschrijving</label>
<textarea id="editRoadmapDescription" rows="3" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Kwartaal</label>
<select id="editRoadmapQuarter" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="Q1">Q1</option>
<option value="Q2">Q2</option>
<option value="Q3">Q3</option>
<option value="Q4">Q4</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select id="editRoadmapType" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="FEATURE">Feature</option>
<option value="EPIC">Epic</option>
<option value="MILESTONE">Mijlpaal</option>
<option value="TASK">Taak</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
<select id="editRoadmapPriority" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="CRITICAL">Kritiek</option>
<option value="HIGH">Hoog</option>
<option value="MEDIUM">Normaal</option>
<option value="LOW">Laag</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Status</label>
<select id="editRoadmapStatus" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="PLANNED">Gepland</option>
<option value="IN_PROGRESS">In Uitvoering</option>
<option value="REVIEW">In Review</option>
<option value="COMPLETED">Voltooid</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Jira Issue Key</label>
<input type="text" id="editRoadmapJiraKey" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum</label>
<input type="date" id="editRoadmapDueDate" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
</div>
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<button id="btnSaveEditRoadmap" onclick="submitEditRoadmapItem()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
Opslaan
</button>
<button onclick="closeEditRoadmapModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Roadmap Item Toewijzen & Inplannen in Vrij Blok -->
<div id="roadmapScheduleModal" onclick="if(event.target === this) closeRoadmapScheduleModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 rounded-xl bg-amber-500/20 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<i data-lucide="calendar-plus" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Inplannen in Agenda van Collega</h3>
<p class="text-[11px] text-slate-500 dark:text-slate-400">Plant dit item direct in een vrij tijdblok van de collega</p>
</div>
</div>
<button onclick="closeRoadmapScheduleModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<input type="hidden" id="roadmapScheduleItemId">
<div class="space-y-4">
<!-- Item titel preview -->
<div class="p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs">
<span class="text-[10px] text-slate-400 font-bold uppercase tracking-wider block mb-0.5">Roadmap Item</span>
<span id="roadmapScheduleTitlePreview" class="font-bold text-slate-800 dark:text-slate-200"></span>
</div>
<!-- Collega Selector -->
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Toewijzen aan collega</label>
<select id="roadmapAssigneeSelect" onchange="onRoadmapAssigneeChange()" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<?php foreach ($teamMembers as $tm): ?>
<option value="<?= $tm['id'] ?>" data-has-outlook="<?= $tm['has_outlook'] ? '1' : '0' ?>" <?= $tm['id'] === $currentUserId ? 'selected' : '' ?>>
<?= htmlspecialchars($tm['name']) ?> (<?= $tm['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>) <?= $tm['has_outlook'] ? '— 📅 Outlook Actief' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- Outlook melding -->
<div id="roadmapAssignOutlookNotice" class="p-2 rounded-xl bg-sky-50 dark:bg-sky-950/40 border border-sky-200 dark:border-sky-800/60 flex items-center space-x-2 text-[11px] text-sky-700 dark:text-sky-300">
<i data-lucide="calendar-check" class="w-4 h-4 text-sky-500 flex-shrink-0"></i>
<span>Deze collega heeft Microsoft Outlook gekoppeld. Er wordt gekeken naar echte vrije blokken.</span>
</div>
<!-- Vrij Blok Zoeker -->
<div class="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs">
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-500"></i>
<span>Automatisch vrij blok vinden</span>
</span>
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden (09:00 - 17:00)</span>
</div>
<button type="button" id="btnRoadmapFindSlot" onclick="findFreeSlotForRoadmapAssignee()" class="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-xs shadow-xs transition flex items-center space-x-1.5 flex-shrink-0 cursor-pointer">
<i data-lucide="search" class="w-3.5 h-3.5"></i>
<span>Zoek vrij blok</span>
</button>
</div>
<div id="roadmapFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-xs text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-500 flex-shrink-0"></i>
<span id="roadmapFoundSlotText"></span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Datum</label>
<input type="date" id="roadmapPlanDate" value="<?= date('Y-m-d') ?>" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Starttijd</label>
<input type="time" id="roadmapStartTime" value="10:00" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Duur</label>
<select id="roadmapDurationMinutes" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
<option value="30">30 minuten</option>
<option value="45">45 minuten</option>
<option value="60" selected>1 uur</option>
<option value="90">1.5 uur</option>
<option value="120">2 uur</option>
</select>
</div>
<div class="flex items-center pt-5">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="roadmapSyncOutlook" checked class="rounded text-indigo-600 focus:ring-indigo-500">
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200">Sync met Outlook 365</span>
</label>
</div>
</div>
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<button id="btnSubmitRoadmapSchedule" onclick="submitRoadmapSchedule()" class="flex-1 bg-gradient-to-r from-indigo-600 to-indigo-500 hover:from-indigo-500 hover:to-indigo-400 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
<i data-lucide="check-circle" class="w-4 h-4"></i>
<span>Inplannen in Agenda</span>
</button>
<button onclick="closeRoadmapScheduleModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
</div>
<!-- Rechtermuisknop Contextmenu voor Roadmap Items -->
<div id="roadmapContextMenu" class="hidden fixed z-50 w-56 bg-white dark:bg-slate-900 rounded-xl shadow-2xl border border-slate-200 dark:border-slate-800 py-1.5 text-xs text-slate-700 dark:text-slate-200">
<button onclick="triggerRoadmapCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
<span>Item bewerken</span>
</button>
<button onclick="triggerRoadmapCtxSchedule()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
<i data-lucide="calendar-plus" class="w-4 h-4 text-amber-500"></i>
<span>👤 Toewijzen & vrij blok inplannen</span>
</button>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<div class="px-3.5 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Verplaats naar Kwartaal</div>
<div class="grid grid-cols-4 gap-1 px-3 py-1">
<button onclick="triggerRoadmapCtxMoveQuarter('Q1')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q1</button>
<button onclick="triggerRoadmapCtxMoveQuarter('Q2')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q2</button>
<button onclick="triggerRoadmapCtxMoveQuarter('Q3')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q3</button>
<button onclick="triggerRoadmapCtxMoveQuarter('Q4')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q4</button>
</div>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<button onclick="triggerRoadmapCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Item verwijderen</span>
</button>
</div>
<script>
async function updateRoadmapStatus(id, status) {
await fetch('/api/roadmap.php?action=update_item', {
@@ -323,7 +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'; ?>
+46
View File
@@ -0,0 +1,46 @@
/**
* Fociz Service Worker
* Beheert desktop push notificaties en click events voor agenda-afspraken.
*/
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
// Behandel klik op een notificatie
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url)
? event.notification.data.url
: '/planner.php';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
// Als er al een Fociz tabblad open staat, breng die naar voren
for (let i = 0; i < windowClients.length; i++) {
const client = windowClients[i];
if ('focus' in client) {
if (targetUrl.startsWith('http') && !targetUrl.includes(self.location.host)) {
// Externe meeting link (zoals Teams): open in nieuw tabblad
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
} else {
client.navigate(targetUrl);
return client.focus();
}
}
}
// Geen tabblad open: open een nieuw venster
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
});
+1359
View File
File diff suppressed because it is too large Load Diff
+195 -99
View File
@@ -3,102 +3,155 @@
* Fociz Team & Uitnodigingen Beheer (Enkel voor Admin)
*/
require_once __DIR__ . '/includes/header.php';
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/mailer.php';
$adminUser = require_admin();
$currentUser = $adminUser;
$db = get_db_connection();
$message = null;
$generatedLink = null;
$error = null;
// Uitnodiging aanmaken
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_invite') {
$email = strtolower(trim($_POST['email'] ?? ''));
$role = in_array($_POST['role'] ?? '', ['ADMIN', 'MEMBER']) ? $_POST['role'] : 'MEMBER';
// POST acties afhandelen met CSRF-bescherming
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$csrfToken = $_POST['csrf_token'] ?? null;
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = "Voer een geldig e-mailadres in.";
} else {
// Controleer of e-mail al bestaat
$stmtCheck = $db->prepare("SELECT id FROM users WHERE email = ?");
$stmtCheck->execute([$email]);
if ($stmtCheck->fetch()) {
$error = "Er bestaat al een gebruiker met dit e-mailadres.";
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';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = "Voer een geldig e-mailadres in.";
} else {
$token = create_invite($adminUser['id'], $email, $role);
$baseUrl = rtrim(APP_URL, '/');
$generatedLink = "{$baseUrl}/auth/invite.php?token={$token}";
$message = "Uitnodigingslink succesvol aangemaakt!";
// Controleer of e-mail al bestaat
$stmtCheck = $db->prepare("SELECT id FROM users WHERE email = ?");
$stmtCheck->execute([$email]);
if ($stmtCheck->fetch()) {
$error = "Er bestaat al een gebruiker met dit e-mailadres.";
} else {
$token = create_invite($adminUser['id'], $email, $role);
$baseUrl = rtrim(APP_URL, '/');
$generatedLink = "{$baseUrl}/auth/invite.php?token={$token}";
// 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;
} 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();
// Uitnodiging intrekken
if (isset($_GET['delete_invite'])) {
$inviteId = (int)$_GET['delete_invite'];
$stmtDel = $db->prepare("DELETE FROM invites WHERE id = ?");
$stmtDel->execute([$inviteId]);
header('Location: /users.php');
exit;
}
$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) {}
// Gebruiker verwijderen
if (isset($_GET['delete_user'])) {
$delUserId = (int)$_GET['delete_user'];
if ($delUserId !== (int)$adminUser['id']) {
$stmtDelUser = $db->prepare("DELETE FROM users WHERE id = ?");
$stmtDelUser->execute([$delUserId]);
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;
}
header('Location: /users.php');
exit;
}
// Haal alle gebruikers op
$users = $db->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll();
$users = [];
try {
$users = $db->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll() ?: [];
} catch (Throwable $e) {
error_log("Fout bij ophalen gebruikers: " . $e->getMessage());
}
// Haal alle uitnodigingen op
$invites = $db->query("
SELECT i.*, u.name as inviter_name
FROM invites i
JOIN users u ON i.created_by_id = u.id
ORDER BY i.created_at DESC
")->fetchAll();
$invites = [];
try {
$invites = $db->query("
SELECT i.*, u.name as inviter_name
FROM invites i
JOIN users u ON i.created_by_id = u.id
ORDER BY i.created_at DESC
")->fetchAll() ?: [];
} catch (Throwable $e) {
error_log("Fout bij ophalen uitnodigingen: " . $e->getMessage());
ensure_tables_exist($db);
}
$pageTitle = 'Team & Uitnodigingen — Fociz';
require_once __DIR__ . '/includes/header.php';
?>
<div class="space-y-6">
<div class="space-y-6 max-w-7xl mx-auto pb-12">
<!-- Header -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
<div class="flex items-center space-x-2 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-1">
<div class="fociz-card p-6 sm:p-7">
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
<i data-lucide="shield" class="w-4 h-4"></i>
<span>Beheerderspaneel</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
Team & Uitnodigingen
</h1>
<p class="text-slate-400 text-xs sm:text-sm mt-1">
Fociz is strikt afgesloten: nieuwe collega's kunnen enkel toegang krijgen na het ontvangen van een uitnodigingslink en verplichte 2FA-activatie.
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
Beheer collega's, rollen en uitnodigingslinks voor toegang tot de Fociz omgeving.
</p>
</div>
<?php if ($error): ?>
<div class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-xs font-semibold flex items-center space-x-2">
<div class="p-4 rounded-2xl bg-red-500/10 border border-red-500/30 text-red-700 dark:text-red-400 text-xs font-semibold flex items-center space-x-2">
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<?php if ($message && $generatedLink): ?>
<div class="p-5 rounded-2xl bg-emerald-950/40 border border-emerald-500/40 text-white space-y-2 shadow-xl">
<div class="flex items-center space-x-2 text-emerald-400 font-bold text-xs">
<div class="p-5 rounded-2xl bg-teal-50/80 dark:bg-teal-950/30 border border-teal-200 dark:border-teal-800/60 text-slate-900 dark:text-white space-y-2 shadow-xs transition">
<div class="flex items-center space-x-2 text-teal-700 dark:text-teal-400 font-bold text-xs">
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
<span><?= htmlspecialchars($message) ?></span>
</div>
<p class="text-xs text-slate-300">
<p class="text-xs text-slate-600 dark:text-slate-300">
Kopieer onderstaande beveiligde registratielink en stuur deze naar de collega:
</p>
<div class="flex items-center space-x-2 bg-slate-950 p-2.5 rounded-xl border border-slate-800">
<input type="text" readonly id="inviteLinkInput" value="<?= htmlspecialchars($generatedLink) ?>" class="bg-transparent text-emerald-300 font-mono text-xs flex-1 outline-none">
<button onclick="copyInviteLink()" class="px-3 py-1.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold text-xs rounded-lg transition flex items-center space-x-1">
<div class="flex items-center space-x-2 bg-white dark:bg-[#0c0f17] p-2.5 rounded-xl border border-teal-200 dark:border-slate-800">
<input type="text" readonly id="inviteLinkInput" value="<?= htmlspecialchars($generatedLink) ?>" class="bg-transparent text-teal-700 dark:text-teal-300 font-mono text-xs flex-1 outline-none">
<button onclick="copyInviteLink()" class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition flex items-center space-x-1 cursor-pointer">
<i data-lucide="copy" class="w-3.5 h-3.5"></i>
<span id="copyBtnText">Kopiëren</span>
</button>
@@ -107,30 +160,31 @@ $invites = $db->query("
<?php endif; ?>
<!-- Uitnodiging Genereren Kaart -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
<h3 class="text-base font-bold text-white mb-4 flex items-center space-x-2">
<i data-lucide="user-plus" class="w-5 h-5 text-emerald-400"></i>
<div class="fociz-card p-6 sm:p-7">
<h3 class="text-base font-bold text-slate-900 dark:text-white mb-4 flex items-center space-x-2">
<i data-lucide="user-plus" class="w-5 h-5 text-indigo-600 dark:text-indigo-400"></i>
<span>Nieuwe Collega Uitnodigen</span>
</h3>
<form method="POST" action="/users.php" class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
<input type="hidden" name="action" value="create_invite">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div class="md:col-span-6">
<label class="block text-xs font-medium text-slate-300 mb-1">E-mailadres van collega</label>
<input type="email" name="email" required placeholder="collega@bedrijf.nl" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 text-xs">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres van collega</label>
<input type="email" name="email" required placeholder="collega@bedrijf.nl" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-xs shadow-2xs">
</div>
<div class="md:col-span-3">
<label class="block text-xs font-medium text-slate-300 mb-1">Rol</label>
<select name="role" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-white text-xs focus:outline-none">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Rol</label>
<select name="role" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="MEMBER">Collega (Standaard)</option>
<option value="ADMIN">Mede-beheerder</option>
</select>
</div>
<div class="md:col-span-3">
<button type="submit" class="w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<button type="submit" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<i data-lucide="link" class="w-4 h-4"></i>
<span>Genereer Link</span>
</button>
@@ -139,17 +193,17 @@ $invites = $db->query("
</div>
<!-- Actieve Gebruikers Tabel -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-800">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center space-x-2">
<i data-lucide="users" class="w-4 h-4 text-emerald-400"></i>
<div class="fociz-card p-6 sm:p-7 space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider flex items-center space-x-2">
<i data-lucide="users" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Geregistreerde Teamleden (<?= count($users) ?>)</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs text-slate-300">
<thead class="bg-slate-950/60 text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-800">
<table class="w-full text-left text-xs text-slate-700 dark:text-slate-300">
<thead class="bg-slate-50 dark:bg-[#0c0f17] text-slate-500 dark:text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-200 dark:border-slate-800">
<tr>
<th class="p-3">Naam & E-mail</th>
<th class="p-3">Rol</th>
@@ -158,34 +212,55 @@ $invites = $db->query("
<th class="p-3 text-right">Acties</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60">
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/60">
<?php foreach ($users as $u): ?>
<tr class="hover:bg-slate-800/30 transition">
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
<td class="p-3">
<div class="font-bold text-white"><?= htmlspecialchars($u['name']) ?></div>
<div class="text-[11px] text-slate-400 font-mono"><?= htmlspecialchars($u['email']) ?></div>
<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-emerald-500/20 text-emerald-400 border border-emerald-500/30' : 'bg-slate-800 text-slate-300' ?>">
<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' ?>">
<?= $u['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
</span>
</td>
<td class="p-3">
<span class="px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[10px] font-medium flex items-center space-x-1 w-max">
<span class="px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60 text-[10px] font-medium flex items-center space-x-1 w-max">
<i data-lucide="shield-check" class="w-3 h-3"></i>
<span>2FA Actief</span>
</span>
</td>
<td class="p-3 text-slate-400">
<td class="p-3 text-slate-500 dark:text-slate-400">
<?= date('d-m-Y', strtotime($u['created_at'])) ?>
</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-500 hover:text-red-400 transition p-1">
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
</a>
<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>
</button>
</form>
<?php else: ?>
<span class="text-[10px] text-slate-600 font-medium">Uzelf</span>
<span class="text-[10px] text-slate-400 dark:text-slate-600 font-medium">Uzelf</span>
<?php endif; ?>
</td>
</tr>
@@ -196,17 +271,17 @@ $invites = $db->query("
</div>
<!-- Uitstaande Uitnodigingen Tabel -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-800">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center space-x-2">
<i data-lucide="mail" class="w-4 h-4 text-amber-400"></i>
<div class="fociz-card p-6 sm:p-7 space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider flex items-center space-x-2">
<i data-lucide="mail" class="w-4 h-4 text-amber-500"></i>
<span>Uitnodigingen Status (<?= count($invites) ?>)</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs text-slate-300">
<thead class="bg-slate-950/60 text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-800">
<table class="w-full text-left text-xs text-slate-700 dark:text-slate-300">
<thead class="bg-slate-50 dark:bg-[#0c0f17] text-slate-500 dark:text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-200 dark:border-slate-800">
<tr>
<th class="p-3">Uitgenodigd E-mailadres</th>
<th class="p-3">Rol</th>
@@ -215,31 +290,53 @@ $invites = $db->query("
<th class="p-3 text-right">Actie</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60">
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/60">
<?php if (empty($invites)): ?>
<tr><td colspan="5" class="p-4 text-center text-slate-500">Nog geen uitnodigingen verstuurd.</td></tr>
<tr><td colspan="5" class="p-4 text-center text-slate-400 dark:text-slate-500">Nog geen uitnodigingen verstuurd.</td></tr>
<?php else: ?>
<?php foreach ($invites as $inv):
$isUsed = !empty($inv['used_at']);
$isExpired = strtotime($inv['expires_at']) < time();
?>
<tr class="hover:bg-slate-800/30 transition">
<td class="p-3 font-mono"><?= htmlspecialchars($inv['email']) ?></td>
<td class="p-3"><?= htmlspecialchars($inv['role']) ?></td>
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
<td class="p-3 font-mono text-slate-900 dark:text-white"><?= htmlspecialchars($inv['email']) ?></td>
<td class="p-3">
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $inv['role'] === 'ADMIN' ? 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300' ?>">
<?= $inv['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
</span>
</td>
<td class="p-3">
<?php if ($isUsed): ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400">Geaccepteerd</span>
<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>
<?php elseif ($isExpired): ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-400">Verlopen</span>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/15 text-red-700 dark:text-red-400 border border-red-500/30">Verlopen</span>
<?php else: ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400">In afwachting</span>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/30">In afwachting</span>
<?php endif; ?>
</td>
<td class="p-3 text-slate-400"><?= date('d-m-Y H:i', strtotime($inv['expires_at'])) ?></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-500 hover:text-red-400 transition p-1">
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
</a>
<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>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
@@ -263,4 +360,3 @@ function copyInviteLink() {
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>