Compare commits
533 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43ebc8f7bf | |||
| 35d4e37d79 | |||
| a4412c081a | |||
| 3b81ad058a | |||
| 9099af78d7 | |||
| a5f672726a | |||
| e76e92b695 | |||
| d488979109 | |||
| 3be309c0fe | |||
| 1443d8d9b2 | |||
| ce09b18a6f | |||
| a9294b0231 | |||
| d7922351c0 | |||
| fa05961404 | |||
| ed199d7a00 | |||
| 32f77425de | |||
| 90867df452 | |||
| 645907b38b | |||
| 500d7de548 | |||
| 6c28c51373 | |||
| 086ab9416a | |||
| 31537efe18 | |||
| b7416e33ea | |||
| d882325da2 | |||
| ecd587fd8c | |||
| a171e4a713 | |||
| 97479aaa35 | |||
| 44f278b7ab | |||
| 279a5a34ab | |||
| ef4f73ad9e | |||
| dde98bb598 | |||
| f60e8520e7 | |||
| 0ab1441fe4 | |||
| 92b439dabf | |||
| 92e0c0dd3b | |||
| dcad2f160d | |||
| ab1f541b53 | |||
| 06de428e61 | |||
| 758607d05f | |||
| 49dd79e3ba | |||
| b97c2310b8 | |||
| ad7cf7514e | |||
| cb095ab7ee | |||
| a7dbd7cd85 | |||
| 3fb8266a9e | |||
| e88ef38222 | |||
| 8d6e0c85b2 | |||
| fc8276494d | |||
| b859b733f6 | |||
| 3734a976d2 | |||
| 2eaca502b2 | |||
| 96e16ea80d | |||
| 15043f0b0c | |||
| 76ab4c0e7d | |||
| dc8febe5f8 | |||
| e10dd6e94a | |||
| 4a2297f4b0 | |||
| 81ed21b6b4 | |||
| bcd4c8cd54 | |||
| 487fd96490 | |||
| 6498f7ca65 | |||
| 0d88409508 | |||
| bbe4bc06c2 | |||
| c1aa1e8f10 | |||
| 2900e0d188 | |||
| 7a4a9f2c14 | |||
| 08ccb6d174 | |||
| 5a6036b0d5 | |||
| 3420c5be5c | |||
| fbfababb22 | |||
| caa6a62e67 | |||
| e8989f2016 | |||
| 0d4fc59904 | |||
| e9f104f40b | |||
| 149a49d6f3 | |||
| ddcb428233 | |||
| 5160967c75 | |||
| 0db546ad79 | |||
| d582a2ba30 | |||
| f9244f5a20 | |||
| 1dbf66cdec | |||
| cdd5bd98bc | |||
| 1f6032941a | |||
| 2b02742bc4 | |||
| 61c29ced21 | |||
| 738100a38a | |||
| 0c67e6a4f4 | |||
| c15e79c5a1 | |||
| 080b9ab795 | |||
| 1211b43f46 | |||
| 9c2c909f71 | |||
| af8ba0b7b5 | |||
| 0813a70184 | |||
| 36920bc9d5 | |||
| a62ccf9c0c | |||
| 3c57a3ed65 | |||
| b149c52931 | |||
| fba2b1213f | |||
| 0251b08113 | |||
| 1ced248940 | |||
| 7f87e4f00a | |||
| c4958d7e4c | |||
| 6c9f2d86a6 | |||
| 86581cea72 | |||
| 83af1599f9 | |||
| c4994ad175 | |||
| 0a13a74b8b | |||
| 636343019c | |||
| 975dc1e1f8 | |||
| d88fc133d2 | |||
| 193a265109 | |||
| 9e5c2cb92e | |||
| bf19cd5015 | |||
| 7fcf4d5ea8 | |||
| d6e3acd17a | |||
| 022d37f1be | |||
| ef1923baa6 | |||
| 3584f72bf5 | |||
| f9e0b323ce | |||
| ad03dba3bf | |||
| a30fd6d6c0 | |||
| 81a107147c | |||
| 5adf1a9a62 | |||
| 5ea88d2d6c | |||
| bd1b4c9a22 | |||
| 567da9d741 | |||
| 748d0bbff4 | |||
| a82fb22b93 | |||
| 9cde8ac9d0 | |||
| 2113c27a68 | |||
| c501784bbe | |||
| 41480e6666 | |||
| c355585355 | |||
| b5411945b3 | |||
| b71336e749 | |||
| d12a6c4670 | |||
| 3f017f82f8 | |||
| 4c39241d3c | |||
| 3fa6c68464 | |||
| b2170dd9d5 | |||
| e61b0855bd | |||
| 9d23eb3297 | |||
| cd677d1736 | |||
| dc919c58a9 | |||
| 029c0e44c9 | |||
| 67ab46879c | |||
| af18853d30 | |||
| e00f9e44f5 | |||
| b384ad1193 | |||
| 2b017a9395 | |||
| 65b1061088 | |||
| f9db3efd39 | |||
| 98413f87ee | |||
| 2cceb8961f | |||
| 6c990b414b | |||
| ceab45b7aa | |||
| 1870cc0f6e | |||
| 9dc4ef2fac | |||
| fcf1631e0f | |||
| 36d4fb8c40 | |||
| f38e1bfc47 | |||
| d1a8b3f350 | |||
| e02ee396dc | |||
| f011bfc43e | |||
| 53e6c3f7c8 | |||
| a7b023e359 | |||
| 363e999dcd | |||
| 60cb617d7f | |||
| aabf043b60 | |||
| 43067c6240 | |||
| eb557d16dd | |||
| 6939ad5e35 | |||
| 9b41fcbc01 | |||
| 96fc0fe33e | |||
| bd0a906796 | |||
| e5022de6be | |||
| 5138649fcd | |||
| f77f228f07 | |||
| 952144c03c | |||
| a9e85ca5f2 | |||
| 63eeb10033 | |||
| 608005d9ee | |||
| f0710c474b | |||
| f306d94729 | |||
| 445c90e852 | |||
| d838cb9db9 | |||
| 21accd5224 | |||
| 9f0ca0e6f5 | |||
| 346b766b7f | |||
| a9012dec69 | |||
| 7c9535cb25 | |||
| 43c4bbd0b1 | |||
| 2fada01eba | |||
| 0368bd1069 | |||
| 764f194a72 | |||
| 6bdc3bba5a | |||
| 8b1a160c21 | |||
| 686ea6cb53 | |||
| 1a380f973c | |||
| 4e7bdb1631 | |||
| d34bf5202c | |||
| 7a33f610c6 | |||
| 42d2acdaf2 | |||
| 7b4876b669 | |||
| 98fe6f5eae | |||
| a18751fe2a | |||
| 9c62add6d2 | |||
| 5bade2283c | |||
| c2bb904fde | |||
| 2fb4fcd069 | |||
| d6c6832a21 | |||
| 53b95dd240 | |||
| 6638347f92 | |||
| 8967b27f17 | |||
| 5fb4e623db | |||
| 727f87343d | |||
| 6fe809a74b | |||
| 7d331cf87c | |||
| 7963852af6 | |||
| 38d11a5dfd | |||
| 86a5887e83 | |||
| 6b3d93fce4 | |||
| be119e02c6 | |||
| b62ae2d849 | |||
| e7890356e1 | |||
| 3d72af9300 | |||
| 6a7d69ecf8 | |||
| 9fa4f807eb | |||
| f05a982a3a | |||
| 6431f8d908 | |||
| f7d6bfb8d2 | |||
| 8cf5a72f41 | |||
| 202a0e07d8 | |||
| 1dac8ec1aa | |||
| d3776c1026 | |||
| e62d197d2c | |||
| 6c39027229 | |||
| 337afd405e | |||
| 90f2c61c3c | |||
| 464a30c31b | |||
| daf1fd5e92 | |||
| 188895da1b | |||
| fdd91c4b3d | |||
| df1570395d | |||
| 793e6c738b | |||
| f7b9c14db3 | |||
| 325961c8ba | |||
| 5981267f68 | |||
| d3ff8e6b19 | |||
| c734f52444 | |||
| a26c2bfa03 | |||
| fce401a569 | |||
| b17ac56b50 | |||
| f1f8a778bb | |||
| cc4201df4d | |||
| a35cf88c70 | |||
| 2f53564b1a | |||
| ab75d01666 | |||
| cc74bfc728 | |||
| 74dcb1b8ba | |||
| 37aa5aded9 | |||
| 091f915f8f | |||
| 49bd135347 | |||
| 55cdee80a0 | |||
| d1a873dcea | |||
| f17d52b4ef | |||
| 4da979c51d | |||
| 7fc8e83330 | |||
| 5152f9ded0 | |||
| 427392f0d0 | |||
| 3b455d33d4 | |||
| efbc11bee1 | |||
| f47596bd9f | |||
| 85efb768cf | |||
| 62101bd1d2 | |||
| 814d63f434 | |||
| 3cb423a9e2 | |||
| 266805950e | |||
| e2805dca07 | |||
| 7e18722d3c | |||
| 098075010f | |||
| 3bb31e580e | |||
| ed39772a28 | |||
| 22f693884e | |||
| 5f2da42848 | |||
| ccd8b8d762 | |||
| c102b6e89b | |||
| 795a9f4629 | |||
| d6728947c6 | |||
| 2f76050352 | |||
| 181375fe08 | |||
| 1506cc7390 | |||
| d4b28b7974 | |||
| 99cb4e96f4 | |||
| 093125df23 | |||
| 9f5617b424 | |||
| bd8b39ad9b | |||
| 0fbc339447 | |||
| 95ea63db2f | |||
| d3974536ff | |||
| 2657246c80 | |||
| 426c9d62ee | |||
| 0f9d84c6a9 | |||
| ab6e34c4fa | |||
| cc988b7b8c | |||
| 5d284682a7 | |||
| a0790adf18 | |||
| fa4dc41ba9 | |||
| 12383d53fd | |||
| 542a535ce1 | |||
| b78590727b | |||
| c6c5fd45e9 | |||
| d73422ca48 | |||
| 729151b6e4 | |||
| d07492f6bc | |||
| 6ab432e0ff | |||
| 4bfb348082 | |||
| cf0f724323 | |||
| 6ac1f8c5af | |||
| f1adfca1d9 | |||
| d683458213 | |||
| 6b37f0062d | |||
| 3e6953a4a4 | |||
| ddad3a12ca | |||
| 2e638960b8 | |||
| 06bcaea4bf | |||
| 62316bd5a6 | |||
| 232130b6d3 | |||
| 2e2eda4feb | |||
| 6e0d9ea262 | |||
| 9ba2190912 | |||
| 136327cf8c | |||
| 17ea40370a | |||
| 3eec30d407 | |||
| 353d36c52b | |||
| 3d7a0c2559 | |||
| e5b7c6e34a | |||
| b088128856 | |||
| 539ecd51b9 | |||
| e99d08fccf | |||
| 003a7b354a | |||
| 9147c03d4c | |||
| ef981a04ff | |||
| ca52beaca1 | |||
| f0a94f59c5 | |||
| 76d40bb4e3 | |||
| a38942f0fd | |||
| bfd25774d3 | |||
| 57ff7273e4 | |||
| d1c134da24 | |||
| d9ebe3d6fd | |||
| 5ac1d6e760 | |||
| 53dc77c6c6 | |||
| 201db84677 | |||
| a82423469c | |||
| c49e3376d5 | |||
| d4b49a265d | |||
| cf2bf52357 | |||
| 61e758f341 | |||
| 7a0baaa3da | |||
| febd87f4e9 | |||
| 038fe37dba | |||
| be9ba6e31e | |||
| 1b68a983ea | |||
| 0e2f728ceb | |||
| e4dbc6a5c9 | |||
| e647499bb6 | |||
| 92440af7da | |||
| 697e4d6d17 | |||
| 27127ae99c | |||
| 65d75a0213 | |||
| 8137cd3701 | |||
| b98ed77327 | |||
| 4b15e47059 | |||
| cf19b96bf8 | |||
| 1d7cffad88 | |||
| 9eaa77b525 | |||
| 6516a726e9 | |||
| c787d6a1a5 | |||
| 4d01953b7a | |||
| 0d6c4a1be9 | |||
| 24614f9596 | |||
| 184d5180fa | |||
| b34bdee164 | |||
| a848ef6e06 | |||
| 172e5b74c6 | |||
| 7fa9f1ae76 | |||
| 7276ca8861 | |||
| c2afb41ef2 | |||
| 3d69698964 | |||
| 9797e08533 | |||
| 268a9faddd | |||
| 9f9507b784 | |||
| b66f1adb3a | |||
| 7eb6dd5b81 | |||
| 1aa9608722 | |||
| b66081643f | |||
| cf1509540e | |||
| 45f0da76c3 | |||
| d5ce9d5be1 | |||
| 41559a4d66 | |||
| b2c788818f | |||
| ceca769cbf | |||
| a6bd553a27 | |||
| cb5d974fea | |||
| 1cc8db2d20 | |||
| 6f467fa5c6 | |||
| 8819cbc081 | |||
| 049e33ded6 | |||
| 1db445e6c9 | |||
| d73442573d | |||
| b2d356a6b2 | |||
| 5ea4d457fe | |||
| 3f415be882 | |||
| b68993d68d | |||
| 0455ff7f7d | |||
| 42746fe4f9 | |||
| 4899bd093b | |||
| 6c94b7f8f8 | |||
| 04c0a089ac | |||
| a3f8fd4082 | |||
| bfb5441b3f | |||
| 973c6e54c1 | |||
| de728b9139 | |||
| 46d8220466 | |||
| 45bbb8229f | |||
| 80e5b3fdcf | |||
| 2ba6a2717b | |||
| 819955d24b | |||
| 80d631eee0 | |||
| ec3b498233 | |||
| a1241a20fa | |||
| 5ec5ad58df | |||
| 9f82be44cc | |||
| c976b23a6c | |||
| e023d050d2 | |||
| 4ef9268b86 | |||
| 51a92f27dd | |||
| 02c9e7054d | |||
| 19cff1ce7c | |||
| a51a4c303d | |||
| b036530766 | |||
| 76cc7b5bf2 | |||
| 1c4b38ec8f | |||
| c993fe738e | |||
| 3c738f179a | |||
| 1d7f0b2b69 | |||
| bb97f9b364 | |||
| cb3e119a5d | |||
| 88e03326af | |||
| 034cf6da23 | |||
| bf7b02b903 | |||
| 4ee8eb6662 | |||
| 685d8b4a2f | |||
| 9970c7b4c9 | |||
| 3c4151335b | |||
| a2a088cf0f | |||
| c7f5e31b23 | |||
| 3992718204 | |||
| ae1087f401 | |||
| c44ce9d41e | |||
| 7fa72b5489 | |||
| 87695eab03 | |||
| 2beb44712c | |||
| 7139d52100 | |||
| 9befdd60c3 | |||
| 4af36d63f9 | |||
| a30198cb2b | |||
| dcc2594195 | |||
| b056438814 | |||
| 367b79bf0b | |||
| aefe858106 | |||
| 6e66c2ac34 | |||
| d09d8f0579 | |||
| e95f078bbc | |||
| 88020ccfe5 | |||
| 96cf29b98b | |||
| 1add54f457 | |||
| b14b03877c | |||
| bc5809520d | |||
| 773c7759c4 | |||
| fb0ab72675 | |||
| 1685946c07 | |||
| e40df0ad87 | |||
| 91751b23ca | |||
| ccf129f1a4 | |||
| 4bf9fcc165 | |||
| ad9999fdd5 | |||
| 26e72f4f73 | |||
| 5686b9fb7d | |||
| 0bb4fac9f9 | |||
| 44f3b3072f | |||
| 104c0d9f94 | |||
| d7bbb68b5e | |||
| aeaa086b40 | |||
| 1663721c89 | |||
| afc7f64027 | |||
| 4c9fcdeb29 | |||
| 3e6b8cae1f | |||
| e3b946f7e9 | |||
| ddf72cc4f0 | |||
| 8137b42c5f | |||
| c0153a2aef | |||
| d607cc54bb | |||
| 24bafdcce4 | |||
| 051bcb08a4 | |||
| 97fb21a632 | |||
| 626b35bb40 | |||
| 090e703214 | |||
| 71cc875f57 | |||
| 34d2aed721 | |||
| 9fba1ac1c3 | |||
| f4112f3160 | |||
| 6f38d53335 | |||
| 59f9afe9fe | |||
| 0c5437a24f | |||
| e3791fa583 | |||
| b0387982bf | |||
| ad1723cce9 | |||
| a9dc2da891 | |||
| 3f5e3097d3 | |||
| 2c98417f50 | |||
| 10b08b5043 | |||
| f4a77ecd25 | |||
| 4db3140ece | |||
| c325d3cdf7 | |||
| 195f8b7115 | |||
| 6bacfa28da | |||
| 638a2ee895 | |||
| f117d66626 | |||
| 7820663c78 | |||
| e3db0a2ae1 | |||
| b58539aeaa |
23
.env
23
.env
@ -27,7 +27,7 @@ NEXT_PUBLIC_IFRAME_LEARN_URL=https://apprendre.slm-lab.net
|
||||
NEXT_PUBLIC_IFRAME_PAROLE_URL=https://parole.slm-lab.net/channel/City
|
||||
NEXT_PUBLIC_IFRAME_CHAPTER_URL=https://chapitre.slm-lab.net
|
||||
NEXT_PUBLIC_IFRAME_AGILITY_URL=https://agilite.slm-lab.net/oidc/login
|
||||
NEXT_PUBLIC_IFRAME_ARTLAB_URL=https://artlab.slm-lab.net
|
||||
NEXT_PUBLIC_IFRAME_DESIGN_URL=https://artlab.slm-lab.net
|
||||
NEXT_PUBLIC_IFRAME_GITE_URL=https://gite.slm-lab.net/user/oauth2/cube
|
||||
NEXT_PUBLIC_IFRAME_CALCULATION_URL=https://calcul.slm-lab.net
|
||||
NEXT_PUBLIC_IFRAME_MEDIATIONS_URL=https://connect.slm-lab.net/realms/cercle/protocol/openid-connect/auth?client_id=mediations.slm-lab.net&redirect_uri=https%3A%2F%2Fmediations.slm-lab.net%2F%3Fopenid_mode%3Dtrue&scope=openid%20profile%20email&response_type=code
|
||||
@ -45,7 +45,7 @@ NEXT_PUBLIC_IFRAME_ANNOUNCEMENT_URL=https://espace.slm-lab.net/apps/announcement
|
||||
NEXT_PUBLIC_IFRAME_HEALTHVIEW_URL=https://espace.slm-lab.net/apps/health/?embedMode=true&hideNavigation=true
|
||||
NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL=https://connect.slm-lab.net/realms/cercle/protocol/openid-connect/auth?response_type=code&scope=openid&client_id=page.slm-lab.net&state=f72528f6756bc132e76dd258691b71cf&redirect_uri=https%3A%2F%2Fpage.slm-lab.net%2Fwp-admin%2F
|
||||
NEXT_PUBLIC_IFRAME_USERSVIEW_URL=https://example.com/users-view
|
||||
NEXT_PUBLIC_IFRAME_THEMESSAGE_URL=https://lemessage.slm-lab.net/admin/
|
||||
NEXT_PUBLIC_IFRAME_THEMESSAGE_URL=https://lemessage.slm-lab.net/auth/oidc
|
||||
NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL=https://alma.slm-lab.net
|
||||
|
||||
ROCKET_CHAT_TOKEN=w91TYgkH-Z67Oz72usYdkW5TZLLRwnre7qyAhp7aHJB
|
||||
@ -77,3 +77,22 @@ IMAP_HOST=mail.infomaniak.com
|
||||
IMAP_PORT=993
|
||||
|
||||
NEWS_API_URL="http://172.16.0.104:8000"
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=mySecretPassword
|
||||
|
||||
MICROSOFT_CLIENT_ID="afaffea5-4e10-462a-aa64-e73baf642c57"
|
||||
MICROSOFT_CLIENT_SECRET="eIx8Q~N3ZnXTjTsVM3ECZio4G7t.BO6AYlD1-b2h"
|
||||
MICROSOFT_REDIRECT_URI="https://lab.slm-lab.net/ms"
|
||||
MICROSOFT_TENANT_ID="cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2"
|
||||
|
||||
|
||||
# Reduce session size to prevent chunking
|
||||
NEXTAUTH_JWT_CALLBACK_STORE_SESSION_DATA=false
|
||||
NEXTAUTH_SESSION_STORE_SESSION_TOKEN=false
|
||||
NEXTAUTH_JWT_STORE_RAW_TOKEN=false
|
||||
# Cookie security and sharing
|
||||
NEXTAUTH_COOKIE_DOMAIN=.slm-lab.net
|
||||
NEXTAUTH_URL=https://lab.slm-lab.net
|
||||
NEXTAUTH_COOKIE_SECURE=true
|
||||
NEXTAUTH_COOKIE_SAMESITE=none
|
||||
139
DEPRECATED_FUNCTIONS.md
Normal file
139
DEPRECATED_FUNCTIONS.md
Normal file
@ -0,0 +1,139 @@
|
||||
# Deprecated Functions and Files
|
||||
|
||||
This document lists functions and files that have been deprecated and should not be used in new code.
|
||||
|
||||
## Deprecated Files
|
||||
|
||||
### 1. `lib/email-formatter.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: Use `lib/utils/email-utils.ts` instead
|
||||
- **Reason**: Consolidated email formatting to a single source of truth
|
||||
|
||||
### 2. `lib/mail-parser-wrapper.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: Use functions from `lib/utils/email-utils.ts` instead
|
||||
- **Reason**: Consolidated email formatting and sanitization to a single source of truth
|
||||
|
||||
### 3. `lib/email-parser.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: Use `lib/server/email-parser.ts` for parsing and `lib/utils/email-utils.ts` for sanitization
|
||||
- **Reason**: Consolidated email parsing and formatting to dedicated files
|
||||
|
||||
### 4. `lib/compose-mime-decoder.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: Use `decodeComposeContent` and `encodeComposeContent` functions from `lib/utils/email-utils.ts`
|
||||
- **Reason**: Consolidated MIME handling into the centralized formatter
|
||||
|
||||
## Deprecated Functions
|
||||
|
||||
### 1. `formatEmailForReplyOrForward` in `lib/services/email-service.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: Use `formatEmailForReplyOrForward` from `lib/utils/email-utils.ts`
|
||||
- **Reason**: Consolidated email formatting to a single source of truth
|
||||
|
||||
### 2. `formatSubject` in `lib/services/email-service.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: None specific, handled by centralized formatter
|
||||
- **Reason**: Internal function of the email formatter
|
||||
|
||||
### 3. `createQuoteHeader` in `lib/services/email-service.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: None specific, handled by centralized formatter
|
||||
- **Reason**: Internal function of the email formatter
|
||||
|
||||
## Centralized Email Formatting
|
||||
|
||||
All email formatting is now handled by the centralized formatter in `lib/utils/email-utils.ts`. This file contains:
|
||||
|
||||
1. `formatForwardedEmail`: Format emails for forwarding
|
||||
2. `formatReplyEmail`: Format emails for replying or replying to all
|
||||
3. `formatEmailForReplyOrForward`: Compatibility function that maps to the above two
|
||||
4. `sanitizeHtml`: Safely sanitize HTML content while preserving direction attributes
|
||||
|
||||
Use these functions for all email formatting needs.
|
||||
|
||||
## Email Parsing and Processing Functions
|
||||
|
||||
### 1. `splitEmailHeadersAndBody` (REMOVED)
|
||||
- **Location**: Removed
|
||||
- **Reason**: Email parsing has been centralized in `lib/server/email-parser.ts` and the API endpoint.
|
||||
- **Replacement**: Use the `parseEmail` function from `lib/server/email-parser.ts` which provides a comprehensive parsing solution.
|
||||
|
||||
### 2. `getReplyBody`
|
||||
- **Location**: `app/courrier/page.tsx`
|
||||
- **Reason**: Should use the `ReplyContent` component directly.
|
||||
- **Replacement**: Use `<ReplyContent email={email} type={type} />` directly.
|
||||
- **Status**: Currently marked with `@deprecated` comment, no direct usages found.
|
||||
|
||||
### 3. `generateEmailPreview`
|
||||
- **Location**: `app/courrier/page.tsx`
|
||||
- **Reason**: Should use the `EmailPreview` component directly.
|
||||
- **Replacement**: Use `<EmailPreview email={email} />` directly.
|
||||
- **Status**: Currently marked with `@deprecated` comment, no usages found.
|
||||
|
||||
### 4. `cleanHtml` (REMOVED)
|
||||
- **Location**: Removed from `lib/server/email-parser.ts`
|
||||
- **Reason**: HTML sanitization has been consolidated in `lib/utils/email-utils.ts`.
|
||||
- **Replacement**: Use `sanitizeHtml` from `lib/utils/email-utils.ts`.
|
||||
|
||||
### 5. `processHtml` (REMOVED)
|
||||
- **Location**: Removed from `app/api/parse-email/route.ts`
|
||||
- **Reason**: HTML processing has been consolidated in `lib/utils/email-utils.ts`.
|
||||
- **Replacement**: Use `sanitizeHtml` from `lib/utils/email-utils.ts`.
|
||||
|
||||
## Deprecated API Routes
|
||||
|
||||
### 1. `app/api/mail/[id]/route.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: Use `app/api/courrier/[id]/route.ts` instead.
|
||||
|
||||
### 2. `app/api/mail/route.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: Use `app/api/courrier/route.ts` instead.
|
||||
|
||||
### 3. `app/api/mail/send/route.ts` (REMOVED)
|
||||
- **Status**: Removed
|
||||
- **Replacement**: Use `app/api/courrier/send/route.ts` instead.
|
||||
|
||||
## Deprecated Components
|
||||
|
||||
### ComposeEmail (components/ComposeEmail.tsx) (REMOVED)
|
||||
|
||||
**Status:** Removed
|
||||
**Replacement:** Use `components/email/ComposeEmail.tsx` instead
|
||||
|
||||
This component has been removed in favor of the more modular and better structured version in the email directory. The newer version has the following improvements:
|
||||
|
||||
- Better separation between user message and quoted content in replies/forwards
|
||||
- Improved styling and visual hierarchy
|
||||
- Support for RTL/LTR text direction toggling
|
||||
- More modern UI using Card components instead of a modal
|
||||
- Better state management for email composition
|
||||
|
||||
A compatibility layer has been added to the new component to ensure backward compatibility with existing code that uses the old component. This allows for a smooth transition without breaking changes.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Deprecation (Completed)
|
||||
- Mark all deprecated functions with `@deprecated` comments
|
||||
- Add console warnings to deprecated functions
|
||||
- Document alternatives
|
||||
|
||||
### Phase 2: Removal (Completed)
|
||||
- Remove deprecated files: `lib/email-parser.ts` and `lib/mail-parser-wrapper.ts`
|
||||
- Consolidate all email formatting in `lib/utils/email-utils.ts`
|
||||
- All email parsing now in `lib/server/email-parser.ts`
|
||||
- Update documentation to point to the centralized utilities
|
||||
|
||||
## Server-Client Code Separation
|
||||
|
||||
### Server-side imports in client components
|
||||
- **Status**: Fixed in November 2023
|
||||
- **Issue**: Server-only modules like ImapFlow were being imported directly in client components, causing build errors with messages like "Module not found: Can't resolve 'tls'"
|
||||
- **Fix**:
|
||||
1. Added 'use server' directive to server-only modules
|
||||
2. Created client-safe interfaces in client components
|
||||
3. Added server actions for email operations that need server capabilities
|
||||
4. Refactored ComposeEmail component to avoid direct server imports
|
||||
|
||||
This architecture ensures a clean separation between server and client code, which is essential for Next.js applications, particularly with the App Router. It prevents Node.js-specific modules from being bundled into client-side JavaScript.
|
||||
59
README.md
Normal file
59
README.md
Normal file
@ -0,0 +1,59 @@
|
||||
# NeahFront9
|
||||
|
||||
This project is a modern Next.js dashboard application with various widgets and components.
|
||||
|
||||
## Code Refactoring
|
||||
|
||||
The codebase is being systematically refactored to improve:
|
||||
|
||||
1. **Code Organization**: Moving from monolithic components to modular, reusable ones
|
||||
2. **Maintainability**: Implementing custom hooks for data fetching and state management
|
||||
3. **Performance**: Reducing duplicate code and optimizing renders
|
||||
4. **Consistency**: Creating unified utilities for common operations
|
||||
|
||||
### Completed Refactoring
|
||||
|
||||
The following refactoring tasks have been completed:
|
||||
|
||||
#### Utility Modules
|
||||
|
||||
- **Status Utilities** (`lib/utils/status-utils.ts`): Centralized task status color and label handling
|
||||
- **Date Utilities** (`lib/utils/date-utils.ts`): Common date validation and formatting functions
|
||||
|
||||
#### Custom Hooks
|
||||
|
||||
- **Task Hook** (`hooks/use-tasks.ts`): Reusable hook for fetching and managing task data
|
||||
- **Calendar Events Hook** (`hooks/use-calendar-events.ts`): Reusable hook for calendar event handling
|
||||
|
||||
#### Updated Components
|
||||
|
||||
- **Duties/Flow Component** (`components/flow.tsx`): Simplified to use the custom task hook
|
||||
- **Calendar Component** (`components/calendar.tsx`): Refactored to use the custom calendar events hook
|
||||
|
||||
### In Progress
|
||||
|
||||
- More components will be refactored to follow the same patterns
|
||||
- State management improvements across the application
|
||||
- Better UI responsiveness and accessibility
|
||||
|
||||
## API Documentation
|
||||
|
||||
A documentation endpoint has been created to track code updates:
|
||||
|
||||
```
|
||||
GET /api/code-updates
|
||||
```
|
||||
|
||||
This returns a JSON object with details of all refactoring changes made to the codebase.
|
||||
|
||||
## Development
|
||||
|
||||
To run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
BIN
app/.DS_Store
vendored
BIN
app/.DS_Store
vendored
Binary file not shown.
@ -1,30 +1,70 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import ResponsiveIframe from '@/app/components/responsive-iframe';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
const menuItems = {
|
||||
board: "https://example.com/board",
|
||||
chapter: "https://example.com/chapter",
|
||||
flow: "https://example.com/flow",
|
||||
design: "https://example.com/design",
|
||||
// Use environment variables for real service URLs
|
||||
const menuItems: Record<string, string> = {
|
||||
parole: process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '',
|
||||
alma: process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || '',
|
||||
crm: process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || '',
|
||||
vision: process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || '',
|
||||
showcase: process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || '',
|
||||
agilite: process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || '',
|
||||
dossiers: process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || '',
|
||||
'the-message': process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || '',
|
||||
qg: process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || '',
|
||||
// Use environment variables for these items too
|
||||
board: process.env.NEXT_PUBLIC_IFRAME_BOARD_URL || '',
|
||||
chapter: process.env.NEXT_PUBLIC_IFRAME_CHAPTER_URL || '',
|
||||
flow: process.env.NEXT_PUBLIC_IFRAME_FLOW_URL || '',
|
||||
design: process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '',
|
||||
artlab: process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '',
|
||||
// External URLs
|
||||
gitlab: "https://gitlab.com",
|
||||
crm: "https://example.com/crm",
|
||||
missions: "https://example.com/missions"
|
||||
missions: process.env.NEXT_PUBLIC_IFRAME_MISSIONSBOARD_URL || ''
|
||||
}
|
||||
|
||||
export default async function SectionPage({ params }: { params: { section: string } }) {
|
||||
const { section } = params;
|
||||
const iframeUrl = menuItems[section as keyof typeof menuItems]
|
||||
// Using a different approach for metadata that doesn't directly access params.section
|
||||
export async function generateMetadata({ params }: { params: { section: string } }) {
|
||||
// Safely handle params
|
||||
const paramsObj = await Promise.resolve(params);
|
||||
const sectionName = paramsObj?.section || '';
|
||||
|
||||
if (!iframeUrl) {
|
||||
notFound()
|
||||
// Capitalize first letter
|
||||
const title = sectionName ?
|
||||
sectionName.charAt(0).toUpperCase() + sectionName.slice(1) :
|
||||
'Section';
|
||||
|
||||
return { title };
|
||||
}
|
||||
|
||||
export default async function SectionPage(props: { params: { section: string } }) {
|
||||
// Ensure authentication first
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
redirect("/signin");
|
||||
}
|
||||
|
||||
// Safely extract section using await Promise.resolve
|
||||
const params = await Promise.resolve(props.params);
|
||||
const section = params?.section || '';
|
||||
|
||||
// Check if section exists in our menu items
|
||||
if (!section || !(section in menuItems)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Get the direct URL for this section
|
||||
const directUrl = menuItems[section];
|
||||
|
||||
return (
|
||||
<div className="w-full h-[calc(100vh-8rem)]">
|
||||
<iframe
|
||||
src={iframeUrl}
|
||||
<ResponsiveIframe
|
||||
src={directUrl}
|
||||
className="w-full h-full border-none"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
133
app/api/admin/restore-credentials/route.ts
Normal file
133
app/api/admin/restore-credentials/route.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getRedisClient } from '@/lib/redis';
|
||||
|
||||
// This is an admin-only route to restore email credentials from Redis to the database
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only allow authorized users
|
||||
if (!session.user.role.includes('admin')) {
|
||||
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
|
||||
// Get all email credential keys
|
||||
const keys = await redis.keys('email:credentials:*');
|
||||
console.log(`Found ${keys.length} credential records in Redis`);
|
||||
|
||||
const results = {
|
||||
total: keys.length,
|
||||
processed: 0,
|
||||
success: 0,
|
||||
errors: [] as string[]
|
||||
};
|
||||
|
||||
// Process each key
|
||||
for (const key of keys) {
|
||||
results.processed++;
|
||||
|
||||
try {
|
||||
// Extract user ID from key
|
||||
const userId = key.split(':')[2];
|
||||
if (!userId) {
|
||||
results.errors.push(`Invalid key format: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get credentials from Redis
|
||||
const credStr = await redis.get(key);
|
||||
if (!credStr) {
|
||||
results.errors.push(`No data found for key: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse credentials
|
||||
const creds = JSON.parse(credStr);
|
||||
console.log(`Processing credentials for user ${userId}`, {
|
||||
email: creds.email,
|
||||
host: creds.host
|
||||
});
|
||||
|
||||
// Check if user exists
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (!userExists) {
|
||||
// Create dummy user if needed (this is optional and might not be appropriate in all cases)
|
||||
// Remove or modify this section if you don't want to create placeholder users
|
||||
console.log(`User ${userId} not found, creating placeholder`);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
email: creds.email || 'placeholder@example.com',
|
||||
password: 'PLACEHOLDER_HASH_CHANGE_THIS', // You should set a proper password
|
||||
// Add any other required fields
|
||||
}
|
||||
});
|
||||
console.log(`Created placeholder user ${userId}`);
|
||||
}
|
||||
|
||||
// Upsert credentials in database
|
||||
await prisma.mailCredentials.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
email: creds.email,
|
||||
password: creds.encryptedPassword || 'encrypted_placeholder',
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
// Optional fields
|
||||
...(creds.secure !== undefined && { secure: creds.secure }),
|
||||
...(creds.smtp_host && { smtp_host: creds.smtp_host }),
|
||||
...(creds.smtp_port && { smtp_port: creds.smtp_port }),
|
||||
...(creds.smtp_secure !== undefined && { smtp_secure: creds.smtp_secure }),
|
||||
...(creds.display_name && { display_name: creds.display_name }),
|
||||
...(creds.color && { color: creds.color })
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
email: creds.email,
|
||||
password: creds.encryptedPassword || 'encrypted_placeholder',
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
// Optional fields
|
||||
...(creds.secure !== undefined && { secure: creds.secure }),
|
||||
...(creds.smtp_host && { smtp_host: creds.smtp_host }),
|
||||
...(creds.smtp_port && { smtp_port: creds.smtp_port }),
|
||||
...(creds.smtp_secure !== undefined && { smtp_secure: creds.smtp_secure }),
|
||||
...(creds.display_name && { display_name: creds.display_name }),
|
||||
...(creds.color && { color: creds.color })
|
||||
}
|
||||
});
|
||||
|
||||
results.success++;
|
||||
console.log(`Successfully restored credentials for user ${userId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
results.errors.push(`Error processing ${key}: ${message}`);
|
||||
console.error(`Error processing ${key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Credential restoration process completed',
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in restore credentials route:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to restore credentials', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/api/admin/view-redis-credentials/route.ts
Normal file
65
app/api/admin/view-redis-credentials/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getRedisClient } from '@/lib/redis';
|
||||
|
||||
// This route just views Redis email credentials without making any changes
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
|
||||
// Get all email credential keys
|
||||
const keys = await redis.keys('email:credentials:*');
|
||||
console.log(`Found ${keys.length} credential records in Redis`);
|
||||
|
||||
const credentials = [];
|
||||
|
||||
// Process each key
|
||||
for (const key of keys) {
|
||||
try {
|
||||
// Extract user ID from key
|
||||
const userId = key.split(':')[2];
|
||||
|
||||
// Get credentials from Redis
|
||||
const credStr = await redis.get(key);
|
||||
if (!credStr) continue;
|
||||
|
||||
// Parse credentials
|
||||
const creds = JSON.parse(credStr);
|
||||
|
||||
// Add to results (remove sensitive data)
|
||||
credentials.push({
|
||||
userId,
|
||||
email: creds.email,
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
hasPassword: !!creds.encryptedPassword,
|
||||
// Include other non-sensitive fields
|
||||
smtp_host: creds.smtp_host,
|
||||
smtp_port: creds.smtp_port,
|
||||
display_name: creds.display_name,
|
||||
color: creds.color
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
count: credentials.length,
|
||||
credentials
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error viewing Redis credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to view credentials', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,11 @@
|
||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
// Define Keycloak profile type
|
||||
interface KeycloakProfile {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
roles?: string[];
|
||||
preferred_username?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
@ -15,13 +14,18 @@ interface KeycloakProfile {
|
||||
};
|
||||
}
|
||||
|
||||
interface DecodedToken {
|
||||
realm_access?: {
|
||||
roles: string[];
|
||||
};
|
||||
[key: string]: any;
|
||||
// Define custom profile type
|
||||
interface CustomProfile {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string[];
|
||||
}
|
||||
|
||||
// Declare module augmentation for NextAuth types
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
@ -33,7 +37,6 @@ declare module "next-auth" {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string[];
|
||||
nextcloudInitialized?: boolean;
|
||||
};
|
||||
accessToken?: string;
|
||||
}
|
||||
@ -42,162 +45,127 @@ declare module "next-auth" {
|
||||
sub?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
accessTokenExpires?: number;
|
||||
role?: string[];
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
error?: string;
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredEnvVar(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(token: JWT) {
|
||||
try {
|
||||
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: token.refreshToken,
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const refreshedTokens = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw refreshedTokens;
|
||||
}
|
||||
|
||||
return {
|
||||
...token,
|
||||
accessToken: refreshedTokens.access_token,
|
||||
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
|
||||
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error refreshing access token:", error);
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Simple, minimal implementation - NO REFRESH TOKEN LOGIC
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
KeycloakProvider({
|
||||
clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"),
|
||||
clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"),
|
||||
issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"),
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid profile email roles"
|
||||
}
|
||||
},
|
||||
profile(profile) {
|
||||
console.log('Keycloak profile callback:', {
|
||||
rawProfile: profile,
|
||||
rawRoles: profile.roles,
|
||||
realmAccess: profile.realm_access,
|
||||
groups: profile.groups
|
||||
});
|
||||
|
||||
// Get roles from realm_access
|
||||
const roles = profile.realm_access?.roles || [];
|
||||
console.log('Profile callback raw roles:', roles);
|
||||
|
||||
// Clean up roles by removing ROLE_ prefix and converting to lowercase
|
||||
const cleanRoles = roles.map((role: string) =>
|
||||
role.replace(/^ROLE_/, '').toLowerCase()
|
||||
);
|
||||
|
||||
console.log('Profile callback cleaned roles:', cleanRoles);
|
||||
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID || "",
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "",
|
||||
issuer: process.env.KEYCLOAK_ISSUER || "",
|
||||
profile(profile: any) {
|
||||
// Just return a simple profile with required fields
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name ?? profile.preferred_username,
|
||||
name: profile.name || profile.preferred_username,
|
||||
email: profile.email,
|
||||
first_name: profile.given_name ?? '',
|
||||
last_name: profile.family_name ?? '',
|
||||
username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '',
|
||||
role: cleanRoles,
|
||||
}
|
||||
},
|
||||
image: null,
|
||||
username: profile.preferred_username || profile.email?.split('@')[0] || '',
|
||||
first_name: profile.given_name || '',
|
||||
last_name: profile.family_name || '',
|
||||
role: ['user'],
|
||||
// Store raw profile data for later processing
|
||||
raw_profile: profile
|
||||
};
|
||||
}
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
maxAge: 8 * 60 * 60, // 8 hours
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
if (account && profile) {
|
||||
const keycloakProfile = profile as KeycloakProfile;
|
||||
const roles = keycloakProfile.realm_access?.roles || [];
|
||||
const cleanRoles = roles.map((role: string) =>
|
||||
role.replace(/^ROLE_/, '').toLowerCase()
|
||||
);
|
||||
|
||||
token.accessToken = account.access_token ?? '';
|
||||
token.refreshToken = account.refresh_token ?? '';
|
||||
token.accessTokenExpires = account.expires_at ?? 0;
|
||||
token.sub = keycloakProfile.sub;
|
||||
token.role = cleanRoles;
|
||||
token.username = keycloakProfile.preferred_username ?? '';
|
||||
token.first_name = keycloakProfile.given_name ?? '';
|
||||
token.last_name = keycloakProfile.family_name ?? '';
|
||||
} else if (token.accessToken) {
|
||||
try {
|
||||
const decoded = jwtDecode<DecodedToken>(token.accessToken);
|
||||
if (decoded.realm_access?.roles) {
|
||||
const roles = decoded.realm_access.roles;
|
||||
const cleanRoles = roles.map((role: string) =>
|
||||
role.replace(/^ROLE_/, '').toLowerCase()
|
||||
);
|
||||
token.role = cleanRoles;
|
||||
async jwt({ token, account, profile, user }: any) {
|
||||
// Initial sign in
|
||||
if (account && account.access_token) {
|
||||
token.accessToken = account.access_token;
|
||||
token.refreshToken = account.refresh_token;
|
||||
|
||||
// Process the raw profile data if available
|
||||
if (user && user.raw_profile) {
|
||||
const rawProfile = user.raw_profile;
|
||||
|
||||
// Extract roles from all possible sources
|
||||
let roles: string[] = [];
|
||||
|
||||
// Get roles from realm_access
|
||||
if (rawProfile.realm_access && Array.isArray(rawProfile.realm_access.roles)) {
|
||||
roles = roles.concat(rawProfile.realm_access.roles);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding token:', error);
|
||||
|
||||
// Get roles from resource_access
|
||||
if (rawProfile.resource_access) {
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
||||
if (clientId &&
|
||||
rawProfile.resource_access[clientId] &&
|
||||
Array.isArray(rawProfile.resource_access[clientId].roles)) {
|
||||
roles = roles.concat(rawProfile.resource_access[clientId].roles);
|
||||
}
|
||||
|
||||
// Also check resource_access roles under 'account'
|
||||
if (rawProfile.resource_access.account &&
|
||||
Array.isArray(rawProfile.resource_access.account.roles)) {
|
||||
roles = roles.concat(rawProfile.resource_access.account.roles);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up roles and convert to lowercase
|
||||
const cleanedRoles = roles
|
||||
.filter(Boolean)
|
||||
.map(role => role.toLowerCase());
|
||||
|
||||
// Always ensure user has basic user role
|
||||
const finalRoles = [...new Set([...cleanedRoles, 'user'])];
|
||||
|
||||
// Map Keycloak roles to application roles
|
||||
token.role = mapToApplicationRoles(finalRoles);
|
||||
} else if (user && user.role) {
|
||||
token.role = Array.isArray(user.role) ? user.role : [user.role];
|
||||
} else {
|
||||
// Default roles if no profile data available
|
||||
token.role = ['user'];
|
||||
}
|
||||
|
||||
// Store user information
|
||||
if (user) {
|
||||
token.username = user.username || user.name || '';
|
||||
token.first_name = user.first_name || '';
|
||||
token.last_name = user.last_name || '';
|
||||
}
|
||||
}
|
||||
// Token exists but no roles, add default user role
|
||||
else if (token && !token.role) {
|
||||
token.role = ['user'];
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: any) {
|
||||
// Pass necessary info to the session
|
||||
session.accessToken = token.accessToken;
|
||||
if (session.user) {
|
||||
session.user.id = token.sub || "";
|
||||
|
||||
// Ensure roles are passed to the session
|
||||
if (token.role && Array.isArray(token.role)) {
|
||||
session.user.role = token.role;
|
||||
session.user.username = token.username || '';
|
||||
session.user.first_name = token.first_name || '';
|
||||
session.user.last_name = token.last_name || '';
|
||||
} else {
|
||||
// Fallback roles
|
||||
session.user.role = ["user"];
|
||||
session.user.username = '';
|
||||
session.user.first_name = '';
|
||||
session.user.last_name = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() < (token.accessTokenExpires as number) * 1000) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return refreshAccessToken(token);
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token.error) {
|
||||
throw new Error(token.error);
|
||||
}
|
||||
|
||||
const userRoles = Array.isArray(token.role) ? token.role : [];
|
||||
session.user = {
|
||||
id: token.sub ?? '',
|
||||
email: token.email ?? null,
|
||||
name: token.name ?? null,
|
||||
image: null,
|
||||
username: token.username ?? '',
|
||||
first_name: token.first_name ?? '',
|
||||
last_name: token.last_name ?? '',
|
||||
role: userRoles,
|
||||
nextcloudInitialized: false,
|
||||
};
|
||||
session.accessToken = token.accessToken;
|
||||
|
||||
return session;
|
||||
}
|
||||
},
|
||||
@ -205,22 +173,57 @@ export const authOptions: NextAuthOptions = {
|
||||
signIn: '/signin',
|
||||
error: '/signin',
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: 'next-auth.session-token',
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'none',
|
||||
path: '/',
|
||||
secure: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps Keycloak roles to application-specific roles
|
||||
*/
|
||||
function mapToApplicationRoles(keycloakRoles: string[]): string[] {
|
||||
const mappings: Record<string, string[]> = {
|
||||
// Map Keycloak roles to your application's role names
|
||||
'admin': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation'],
|
||||
'owner': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation'],
|
||||
'cercle-admin': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation'],
|
||||
'manager': ['dataintelligence', 'coding', 'expression', 'mediation'],
|
||||
'developer': ['coding', 'dataintelligence'],
|
||||
'data-scientist': ['dataintelligence'],
|
||||
'designer': ['expression'],
|
||||
'writer': ['expression'],
|
||||
'mediator': ['mediation'],
|
||||
// Default access roles from Keycloak
|
||||
'default-roles-cercle': ['user'],
|
||||
'uma_authorization': ['user'],
|
||||
'offline_access': ['user'],
|
||||
// Add more mappings as needed
|
||||
};
|
||||
|
||||
// Map each role and flatten the result
|
||||
let appRoles: string[] = ['user']; // Always include 'user' role
|
||||
|
||||
for (const role of keycloakRoles) {
|
||||
const mappedRoles = mappings[role.toLowerCase()];
|
||||
if (mappedRoles) {
|
||||
appRoles = [...appRoles, ...mappedRoles];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and return
|
||||
return [...new Set(appRoles)];
|
||||
}
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
interface JWT {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpires: number;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
sub?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
|
||||
308
app/api/auth/[...nextauth]/route.ts.bak
Normal file
308
app/api/auth/[...nextauth]/route.ts.bak
Normal file
@ -0,0 +1,308 @@
|
||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
interface KeycloakProfile {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
roles?: string[];
|
||||
preferred_username?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
realm_access?: {
|
||||
roles: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface DecodedToken {
|
||||
realm_access?: {
|
||||
roles: string[];
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string[];
|
||||
nextcloudInitialized?: boolean;
|
||||
};
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
interface JWT {
|
||||
sub?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
accessTokenExpires?: number;
|
||||
role?: string[];
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
error?: string;
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredEnvVar(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(token: JWT) {
|
||||
try {
|
||||
console.log('Attempting to refresh access token');
|
||||
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: token.refreshToken,
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const refreshedTokens = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Token refresh failed with status:', response.status);
|
||||
console.error('Error response:', refreshedTokens);
|
||||
throw refreshedTokens;
|
||||
}
|
||||
|
||||
console.log('Token refresh successful');
|
||||
return {
|
||||
...token,
|
||||
accessToken: refreshedTokens.access_token,
|
||||
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
|
||||
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
|
||||
error: undefined, // Clear any previous errors
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error refreshing access token:", error);
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
KeycloakProvider({
|
||||
clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"),
|
||||
clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"),
|
||||
issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"),
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid profile email roles"
|
||||
}
|
||||
},
|
||||
profile(profile) {
|
||||
// Simplified profile logging to reduce console noise
|
||||
console.log('Keycloak profile received');
|
||||
|
||||
// Get roles from realm_access
|
||||
const roles = profile.realm_access?.roles || [];
|
||||
|
||||
// Clean up roles by removing ROLE_ prefix and converting to lowercase
|
||||
const cleanRoles = roles.map((role: string) =>
|
||||
role.replace(/^ROLE_/, '').toLowerCase()
|
||||
);
|
||||
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name ?? profile.preferred_username,
|
||||
email: profile.email,
|
||||
first_name: profile.given_name ?? '',
|
||||
last_name: profile.family_name ?? '',
|
||||
username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '',
|
||||
role: cleanRoles,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 12 * 60 * 60, // Reduce to 12 hours to help with token size
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'none',
|
||||
path: '/',
|
||||
secure: true,
|
||||
domain: process.env.NEXTAUTH_COOKIE_DOMAIN || undefined,
|
||||
maxAge: 12 * 60 * 60, // Match session maxAge
|
||||
},
|
||||
},
|
||||
},
|
||||
jwt: {
|
||||
// Maximum JWT size to prevent chunking
|
||||
maxAge: 12 * 60 * 60, // Reduce to 12 hours
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
// Initial sign in
|
||||
if (account && profile) {
|
||||
const keycloakProfile = profile as KeycloakProfile;
|
||||
const roles = keycloakProfile.realm_access?.roles || [];
|
||||
|
||||
// Only include admin, owner, user roles (most critical)
|
||||
const criticalRoles = roles
|
||||
.filter(role =>
|
||||
role.includes('admin') ||
|
||||
role.includes('owner') ||
|
||||
role.includes('user')
|
||||
)
|
||||
.map(role => role.replace(/^ROLE_/, '').toLowerCase());
|
||||
|
||||
// Store absolute minimal data in the token
|
||||
token.accessToken = account.access_token;
|
||||
token.refreshToken = account.refresh_token;
|
||||
token.accessTokenExpires = account.expires_at ?? 0;
|
||||
token.sub = keycloakProfile.sub;
|
||||
token.role = criticalRoles.length > 0 ? criticalRoles : ['user']; // Only critical roles
|
||||
token.username = keycloakProfile.preferred_username?.substring(0, 30) ?? '';
|
||||
token.error = undefined;
|
||||
|
||||
// Don't store first/last name in the token to save space
|
||||
// Applications can get these from the userinfo endpoint if needed
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
const tokenExpiresAt = token.accessTokenExpires ? token.accessTokenExpires as number : 0;
|
||||
const currentTime = Date.now();
|
||||
const hasExpired = currentTime >= tokenExpiresAt;
|
||||
|
||||
// If the token is still valid, return it
|
||||
if (!hasExpired) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// If refresh token is missing, force sign in
|
||||
if (!token.refreshToken) {
|
||||
console.warn('No refresh token available, session cannot be refreshed');
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError"
|
||||
};
|
||||
}
|
||||
|
||||
// Try to refresh the token
|
||||
const refreshedToken = await refreshAccessToken(token as JWT);
|
||||
|
||||
// If there was an error refreshing, mark token for re-authentication
|
||||
if (refreshedToken.error) {
|
||||
console.warn('Token refresh failed, user will need to reauthenticate');
|
||||
return {
|
||||
...refreshedToken,
|
||||
error: "RefreshAccessTokenError"
|
||||
};
|
||||
}
|
||||
|
||||
return refreshedToken;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
try {
|
||||
// Handle the error from jwt callback
|
||||
if (token.error === "RefreshAccessTokenError") {
|
||||
console.warn("Session encountered a refresh token error, redirecting to login");
|
||||
// Return minimal session with error flag that will trigger re-auth in client
|
||||
return {
|
||||
...session,
|
||||
error: "RefreshTokenError",
|
||||
user: {
|
||||
id: token.sub ?? '',
|
||||
role: ['user'], // Default role
|
||||
username: '', // Empty username
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
name: null,
|
||||
email: null,
|
||||
image: null,
|
||||
nextcloudInitialized: false,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const userRoles = Array.isArray(token.role) ? token.role : [];
|
||||
|
||||
// Create an extremely minimal user object
|
||||
session.user = {
|
||||
id: token.sub ?? '',
|
||||
email: token.email ?? null,
|
||||
name: token.name ?? null,
|
||||
image: null,
|
||||
username: token.username ?? '',
|
||||
first_name: token.first_name ?? '',
|
||||
last_name: token.last_name ?? '',
|
||||
role: userRoles,
|
||||
// Don't include nextcloudInitialized or other non-essential fields
|
||||
};
|
||||
|
||||
// Only store access token, not the entire token
|
||||
session.accessToken = token.accessToken;
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error("Error in session callback:", error);
|
||||
// Return minimal session with error flag
|
||||
return {
|
||||
...session,
|
||||
error: "SessionError",
|
||||
user: {
|
||||
id: token.sub ?? '',
|
||||
role: ['user'],
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
name: null,
|
||||
email: null,
|
||||
image: null,
|
||||
nextcloudInitialized: false,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
signIn: '/signin',
|
||||
error: '/signin',
|
||||
},
|
||||
debug: false, // Disable debug to reduce cookie size from logging
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
interface JWT {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpires: number;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
sub?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
35
app/api/auth/full-logout/route.ts
Normal file
35
app/api/auth/full-logout/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
const userId = session?.user?.id;
|
||||
const keycloakUrl = process.env.KEYCLOAK_ISSUER;
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
||||
const redirectUri = req.nextUrl.searchParams.get('redirectUri') || process.env.NEXTAUTH_URL;
|
||||
|
||||
// Build Keycloak logout URL
|
||||
let logoutUrl = `${keycloakUrl}/protocol/openid-connect/logout?client_id=${clientId}`;
|
||||
|
||||
if (redirectUri) {
|
||||
logoutUrl += `&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`;
|
||||
}
|
||||
|
||||
console.log(`Initiating full Keycloak logout for user ${userId || 'unknown'}`);
|
||||
console.log(`Logout URL: ${logoutUrl}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
logoutUrl,
|
||||
message: "Keycloak logout URL generated"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating Keycloak logout URL:", error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Failed to generate Keycloak logout URL"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
130
app/api/auth/rocket-login/route.ts
Normal file
130
app/api/auth/rocket-login/route.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
// Helper function to get user token using admin credentials
|
||||
async function getUserTokenForRocketChat(email: string) {
|
||||
try {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
|
||||
|
||||
if (!baseUrl) {
|
||||
console.error('Failed to get Rocket.Chat base URL');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Authenticating with Rocket.Chat at ${baseUrl} for user ${email}`);
|
||||
|
||||
// Admin headers for Rocket.Chat API
|
||||
const adminHeaders = {
|
||||
'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!,
|
||||
'X-User-Id': process.env.ROCKET_CHAT_USER_ID!,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Get the username from email
|
||||
const username = email.split('@')[0];
|
||||
console.log(`Derived username: ${username}`);
|
||||
|
||||
// Get all users to find the current user
|
||||
const usersResponse = await fetch(`${baseUrl}/api/v1/users.list`, {
|
||||
method: 'GET',
|
||||
headers: adminHeaders
|
||||
});
|
||||
|
||||
if (!usersResponse.ok) {
|
||||
console.error(`Failed to get users list: ${usersResponse.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const usersData = await usersResponse.json();
|
||||
console.log(`Retrieved ${usersData.users?.length || 0} users from Rocket.Chat`);
|
||||
|
||||
// Find the current user in the list - FIX: properly check email address
|
||||
const currentUser = usersData.users.find((user: any) => {
|
||||
// Check username match
|
||||
if (user.username === username) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check email match in emails array
|
||||
if (user.emails && Array.isArray(user.emails)) {
|
||||
return user.emails.some((emailObj: any) => emailObj.address === email);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
console.error(`User not found in Rocket.Chat users list with username ${username} or email ${email}`);
|
||||
// Try to log some users for debugging
|
||||
const someUsers = usersData.users.slice(0, 3).map((u: any) => ({
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
name: u.name
|
||||
}));
|
||||
console.log('Sample users:', JSON.stringify(someUsers));
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Found user in Rocket.Chat: ${currentUser.username} (${currentUser._id})`);
|
||||
|
||||
// Create a token for the current user
|
||||
const createTokenResponse = await fetch(`${baseUrl}/api/v1/users.createToken`, {
|
||||
method: 'POST',
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify({
|
||||
userId: currentUser._id
|
||||
})
|
||||
});
|
||||
|
||||
if (!createTokenResponse.ok) {
|
||||
console.error(`Failed to create user token: ${createTokenResponse.status}`);
|
||||
const errorText = await createTokenResponse.text();
|
||||
console.error(`Error details: ${errorText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = await createTokenResponse.json();
|
||||
console.log('Successfully created Rocket.Chat token');
|
||||
|
||||
return {
|
||||
authToken: tokenData.data.authToken,
|
||||
userId: currentUser._id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting user token for Rocket.Chat:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get the current user session
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userEmail = session.user.email;
|
||||
console.log(`Processing Rocket.Chat login for user ${userEmail}`);
|
||||
|
||||
// Get a token for Rocket.Chat
|
||||
const rocketChatTokens = await getUserTokenForRocketChat(userEmail);
|
||||
|
||||
if (!rocketChatTokens) {
|
||||
return NextResponse.json({ error: 'Failed to obtain Rocket.Chat tokens' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Return the tokens to the client
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rocketChatToken: rocketChatTokens.authToken,
|
||||
rocketChatUserId: rocketChatTokens.userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in Rocket.Chat login API:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
85
app/api/auth/session-cleanup/route.ts
Normal file
85
app/api/auth/session-cleanup/route.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getRedisClient } from '@/lib/redis';
|
||||
import { closeUserImapConnections } from '@/lib/services/email-service';
|
||||
|
||||
/**
|
||||
* API endpoint to clean up user sessions and invalidate cached data
|
||||
* Called during logout to ensure proper cleanup of all connections
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const userId = session?.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, error: "No authenticated user found" }, { status: 401 });
|
||||
}
|
||||
|
||||
await cleanupUserSessions(userId, false);
|
||||
|
||||
return NextResponse.json({ success: true, userId, message: "Session cleaned up" });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const userId = body.userId;
|
||||
const preserveSso = !!body.preserveSso;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, error: "No user ID provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
await cleanupUserSessions(userId, preserveSso);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
userId,
|
||||
preserveSso,
|
||||
message: `Session cleaned up${preserveSso ? ' (SSO preserved)' : ''}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in session-cleanup POST:", error);
|
||||
return NextResponse.json({ success: false, error: "Failed to parse request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupUserSessions(userId: string, preserveSso: boolean) {
|
||||
try {
|
||||
const redis = await getRedisClient();
|
||||
if (!redis) {
|
||||
console.error("Redis client not available for session cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Cleaning up sessions for user ${userId}${preserveSso ? ' (preserving SSO)' : ''}`);
|
||||
|
||||
// Find all keys for this user
|
||||
const userKeys = await redis.keys(`*:${userId}*`);
|
||||
|
||||
if (userKeys.length > 0) {
|
||||
console.log(`Found ${userKeys.length} keys for user ${userId}:`, userKeys);
|
||||
|
||||
// If preserving SSO, only delete application-specific keys
|
||||
const keysToDelete = preserveSso
|
||||
? userKeys.filter(key => !key.includes('keycloak') && !key.includes('sso'))
|
||||
: userKeys;
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
await redis.del(keysToDelete);
|
||||
console.log(`Deleted ${keysToDelete.length} keys for user ${userId}`);
|
||||
} else {
|
||||
console.log(`No application keys to delete while preserving SSO`);
|
||||
}
|
||||
} else {
|
||||
console.log(`No keys found for user ${userId}`);
|
||||
}
|
||||
|
||||
// Close any active IMAP connections
|
||||
await closeUserImapConnections(userId);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error cleaning up sessions for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
54
app/api/code-updates/route.ts
Normal file
54
app/api/code-updates/route.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
// This API serves as documentation for code refactoring steps
|
||||
export async function GET() {
|
||||
const headersList = headers();
|
||||
|
||||
const updates = [
|
||||
{
|
||||
id: 1,
|
||||
description: "Created consistent task status utilities",
|
||||
file: "lib/utils/status-utils.ts",
|
||||
date: "2023-07-15",
|
||||
details: "Centralized task status color and label handling"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
description: "Created date validation utilities",
|
||||
file: "lib/utils/date-utils.ts",
|
||||
date: "2023-07-15",
|
||||
details: "Added isValidDateString and other date handling utilities"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
description: "Created useTasks custom hook",
|
||||
file: "hooks/use-tasks.ts",
|
||||
date: "2023-07-16",
|
||||
details: "Centralized task fetching logic into a reusable React hook"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
description: "Refactored Duties component in flow.tsx",
|
||||
file: "components/flow.tsx",
|
||||
date: "2023-07-16",
|
||||
details: "Updated to use the new useTasks hook instead of built-in task fetching"
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
description: "Created useCalendarEvents custom hook",
|
||||
file: "hooks/use-calendar-events.ts",
|
||||
date: "2023-07-16",
|
||||
details: "Centralized calendar event fetching logic into a reusable React hook"
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
description: "Refactored Calendar component",
|
||||
file: "components/calendar.tsx",
|
||||
date: "2023-07-16",
|
||||
details: "Updated to use the new useCalendarEvents hook for improved maintainability"
|
||||
}
|
||||
];
|
||||
|
||||
return NextResponse.json({ updates });
|
||||
}
|
||||
70
app/api/courrier/[id]/flag/route.ts
Normal file
70
app/api/courrier/[id]/flag/route.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { toggleEmailFlag } from '@/lib/services/email-service';
|
||||
import { invalidateEmailContentCache, invalidateFolderCache } from '@/lib/redis';
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not authenticated" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Await params as per Next.js requirements
|
||||
const params = await context.params;
|
||||
const id = params?.id;
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing email ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { flagged, folder, accountId } = await request.json();
|
||||
|
||||
if (typeof flagged !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid 'flagged' parameter. Must be a boolean." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedFolder = folder || "INBOX";
|
||||
const effectiveAccountId = accountId || 'default';
|
||||
|
||||
// Use the email service to toggle the flag
|
||||
// Note: You'll need to implement this function in email-service.ts
|
||||
const success = await toggleEmailFlag(
|
||||
session.user.id,
|
||||
id,
|
||||
flagged,
|
||||
normalizedFolder,
|
||||
effectiveAccountId
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to ${flagged ? 'star' : 'unstar'} email` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate cache for this email
|
||||
await invalidateEmailContentCache(session.user.id, effectiveAccountId, id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Error in flag API:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", message: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { markEmailReadStatus } from '@/lib/services/email-service';
|
||||
import { invalidateEmailContentCache, invalidateFolderCache } from '@/lib/redis';
|
||||
|
||||
// Global cache reference (will be moved to a proper cache solution in the future)
|
||||
declare global {
|
||||
@ -30,62 +31,66 @@ const invalidateCache = (userId: string, folder?: string) => {
|
||||
// Mark email as read
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
// Get session
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// No need to await Promise.resolve(), params is already an object
|
||||
const { id: emailId } = params;
|
||||
if (!emailId) {
|
||||
return NextResponse.json({ error: 'Email ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the email service to mark the email as read
|
||||
// First try with INBOX folder
|
||||
let success = await markEmailReadStatus(session.user.id, emailId, true, 'INBOX');
|
||||
|
||||
// If not found in INBOX, try to find it in other common folders
|
||||
if (!success) {
|
||||
const commonFolders = ['Sent', 'Drafts', 'Trash', 'Spam', 'Junk'];
|
||||
|
||||
for (const folder of commonFolders) {
|
||||
success = await markEmailReadStatus(session.user.id, emailId, true, folder);
|
||||
if (success) {
|
||||
// If found in a different folder, invalidate that folder's cache
|
||||
invalidateCache(session.user.id, folder);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Email found in INBOX, invalidate INBOX cache
|
||||
invalidateCache(session.user.id, 'INBOX');
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email not found in any folder' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error marking email as read:', error);
|
||||
if (!session || !session.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to mark email as read' },
|
||||
{ error: "Not authenticated" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Await params as per Next.js requirements
|
||||
const params = await context.params;
|
||||
const id = params?.id;
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing email ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { isRead, folder, accountId } = await request.json();
|
||||
|
||||
if (typeof isRead !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid 'isRead' parameter. Must be a boolean." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedFolder = folder || "INBOX";
|
||||
const effectiveAccountId = accountId || 'default';
|
||||
|
||||
// Use the email service to mark the email
|
||||
const success = await markEmailReadStatus(
|
||||
session.user.id,
|
||||
id,
|
||||
isRead,
|
||||
normalizedFolder,
|
||||
effectiveAccountId
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to ${isRead ? 'mark email as read' : 'mark email as unread'}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking email as read:', error);
|
||||
|
||||
// Invalidate cache for this email
|
||||
await invalidateEmailContentCache(session.user.id, effectiveAccountId, id);
|
||||
|
||||
// Also invalidate folder cache to update unread counts
|
||||
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Error in mark-read API:", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to mark email as read' },
|
||||
{ error: "Internal server error", message: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,10 +10,11 @@ import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getEmailContent, markEmailReadStatus } from '@/lib/services/email-service';
|
||||
import { getCachedEmailContent, invalidateEmailContentCache } from '@/lib/redis';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -24,7 +25,9 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
// Await params as per Next.js requirements
|
||||
const params = await context.params;
|
||||
const id = params?.id;
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing email ID" },
|
||||
@ -34,12 +37,22 @@ export async function GET(
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const folder = searchParams.get("folder") || "INBOX";
|
||||
const accountId = searchParams.get("accountId");
|
||||
|
||||
try {
|
||||
// Use the email service to fetch the email content
|
||||
const email = await getEmailContent(session.user.id, id, folder);
|
||||
// Try to get email from Redis cache first
|
||||
const cachedEmail = await getCachedEmailContent(session.user.id, accountId || 'default', id);
|
||||
if (cachedEmail) {
|
||||
console.log(`Using cached email content for ${session.user.id}:${id}`);
|
||||
return NextResponse.json(cachedEmail);
|
||||
}
|
||||
|
||||
// Return the complete email object instead of just partial data
|
||||
console.log(`Cache miss for email content ${session.user.id}:${id}, fetching from IMAP`);
|
||||
|
||||
// Use the email service to fetch the email content
|
||||
const email = await getEmailContent(session.user.id, id, folder, accountId || undefined);
|
||||
|
||||
// Return the complete email object
|
||||
return NextResponse.json(email);
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching email content:", error);
|
||||
@ -60,7 +73,7 @@ export async function GET(
|
||||
// Add a route to mark email as read
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -71,7 +84,9 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
// Await params as per Next.js requirements
|
||||
const params = await context.params;
|
||||
const id = params?.id;
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing email ID" },
|
||||
@ -90,6 +105,7 @@ export async function POST(
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const folder = searchParams.get("folder") || "INBOX";
|
||||
const accountId = searchParams.get("accountId");
|
||||
|
||||
// Use the email service to mark the email
|
||||
const success = await markEmailReadStatus(
|
||||
@ -106,6 +122,9 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate cache for this email
|
||||
await invalidateEmailContentCache(session.user.id, accountId || 'default', id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Error in POST:", error);
|
||||
|
||||
64
app/api/courrier/account-details/route.ts
Normal file
64
app/api/courrier/account-details/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get accountId from query params
|
||||
const { searchParams } = new URL(request.url);
|
||||
const accountId = searchParams.get('accountId');
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get account details from database, including connection details
|
||||
const account = await prisma.mailCredentials.findFirst({
|
||||
where: {
|
||||
id: accountId,
|
||||
userId: session.user.id
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
host: true,
|
||||
port: true,
|
||||
secure: true,
|
||||
display_name: true,
|
||||
color: true,
|
||||
// Don't include the password in the response
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(account);
|
||||
} catch (error) {
|
||||
console.error('Error fetching account details:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch account details',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
170
app/api/courrier/account-folders/route.ts
Normal file
170
app/api/courrier/account-folders/route.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getMailboxes } from '@/lib/services/email-service';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getCachedEmailCredentials } from '@/lib/redis';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// Verify auth
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const accountId = searchParams.get('accountId');
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
// If specific accountId is provided, get folders for that account
|
||||
if (accountId) {
|
||||
// Get account from database
|
||||
const account = await prisma.mailCredentials.findFirst({
|
||||
where: {
|
||||
id: accountId,
|
||||
userId
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
password: true,
|
||||
host: true,
|
||||
port: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Connect to IMAP server for this account
|
||||
const client = new ImapFlow({
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: account.email,
|
||||
pass: account.password,
|
||||
},
|
||||
logger: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
// Get folders for this account
|
||||
const folders = await getMailboxes(client);
|
||||
|
||||
// Close connection
|
||||
await client.logout();
|
||||
|
||||
return NextResponse.json({
|
||||
accountId,
|
||||
email: account.email,
|
||||
folders
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to connect to IMAP server',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Get all accounts for this user
|
||||
const accounts = await prisma.mailCredentials.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
password: true,
|
||||
host: true,
|
||||
port: true
|
||||
}
|
||||
});
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
accounts: []
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch folders for each account individually
|
||||
const accountsWithFolders = await Promise.all(accounts.map(async (account) => {
|
||||
try {
|
||||
// Connect to IMAP server for this specific account
|
||||
const client = new ImapFlow({
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: account.email,
|
||||
pass: account.password,
|
||||
},
|
||||
logger: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
// Get folders for this account
|
||||
const folders = await getMailboxes(client);
|
||||
|
||||
// Close connection
|
||||
await client.logout();
|
||||
|
||||
// Add display_name and color from database
|
||||
const metadata = await prisma.$queryRaw`
|
||||
SELECT display_name, color
|
||||
FROM "MailCredentials"
|
||||
WHERE id = ${account.id}
|
||||
`;
|
||||
|
||||
const displayMetadata = Array.isArray(metadata) && metadata.length > 0 ? metadata[0] : {};
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
display_name: displayMetadata.display_name || account.email,
|
||||
color: displayMetadata.color || "#0082c9",
|
||||
folders
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching folders for account ${account.email}:`, error);
|
||||
// Return fallback folders on error
|
||||
return {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
folders: ['INBOX', 'Sent', 'Drafts', 'Trash'] // Fallback folders on error
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
accounts: accountsWithFolders
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to get account folders',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
51
app/api/courrier/account-list/route.ts
Normal file
51
app/api/courrier/account-list/route.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get all email accounts for this user
|
||||
const accounts = await prisma.mailCredentials.findMany({
|
||||
where: { userId: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
host: true,
|
||||
port: true,
|
||||
secure: true,
|
||||
display_name: true,
|
||||
color: true,
|
||||
smtp_host: true,
|
||||
smtp_port: true,
|
||||
smtp_secure: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
// Never return passwords
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
accounts
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching email accounts:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch email accounts',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
294
app/api/courrier/account/route.ts
Normal file
294
app/api/courrier/account/route.ts
Normal file
@ -0,0 +1,294 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { saveUserEmailCredentials, testEmailConnection } from '@/lib/services/email-service';
|
||||
import { invalidateFolderCache } from '@/lib/redis';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
// Define EmailCredentials interface inline since we're having import issues
|
||||
interface EmailCredentials {
|
||||
email: string;
|
||||
password?: string;
|
||||
host: string;
|
||||
port: number;
|
||||
secure?: boolean;
|
||||
smtp_host?: string;
|
||||
smtp_port?: number;
|
||||
smtp_secure?: boolean;
|
||||
display_name?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user exists in the database
|
||||
*/
|
||||
async function userExists(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true }
|
||||
});
|
||||
return !!user;
|
||||
} catch (error) {
|
||||
console.error(`Error checking if user exists:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the user exists in the database
|
||||
const userExistsInDB = await userExists(session.user.id);
|
||||
if (!userExistsInDB) {
|
||||
console.error(`User with ID ${session.user.id} not found in database`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'User not found in database',
|
||||
details: `The user ID from your session (${session.user.id}) doesn't exist in the database. This may be due to a session/database mismatch.`
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json().catch(e => {
|
||||
console.error('Error parsing request body:', e);
|
||||
return {};
|
||||
});
|
||||
|
||||
// Log the request (but hide password)
|
||||
console.log('Adding account:', {
|
||||
...body,
|
||||
password: body.password ? '***' : undefined
|
||||
});
|
||||
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
smtp_secure,
|
||||
display_name,
|
||||
color
|
||||
} = body;
|
||||
|
||||
// Validate required fields
|
||||
const missingFields = [];
|
||||
if (!email) missingFields.push('email');
|
||||
if (!password) missingFields.push('password');
|
||||
if (!host) missingFields.push('host');
|
||||
if (port === undefined) missingFields.push('port');
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
console.error(`Missing required fields: ${missingFields.join(', ')}`);
|
||||
return NextResponse.json(
|
||||
{ error: `Required fields missing: ${missingFields.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fix common hostname errors - strip http/https prefixes
|
||||
let cleanHost = host;
|
||||
if (cleanHost.startsWith('http://')) {
|
||||
cleanHost = cleanHost.substring(7);
|
||||
} else if (cleanHost.startsWith('https://')) {
|
||||
cleanHost = cleanHost.substring(8);
|
||||
}
|
||||
|
||||
// Create credentials object
|
||||
const credentials: EmailCredentials = {
|
||||
email,
|
||||
password,
|
||||
host: cleanHost,
|
||||
port: typeof port === 'string' ? parseInt(port) : port,
|
||||
secure: secure ?? true,
|
||||
// Optional SMTP settings
|
||||
...(smtp_host && { smtp_host }),
|
||||
...(smtp_port && { smtp_port: typeof smtp_port === 'string' ? parseInt(smtp_port) : smtp_port }),
|
||||
...(smtp_secure !== undefined && { smtp_secure }),
|
||||
// Optional display settings
|
||||
...(display_name && { display_name }),
|
||||
...(color && { color })
|
||||
};
|
||||
|
||||
// Test connection before saving
|
||||
console.log(`Testing connection before saving for user ${session.user.id}`);
|
||||
const testResult = await testEmailConnection(credentials);
|
||||
|
||||
if (!testResult.imap) {
|
||||
return NextResponse.json(
|
||||
{ error: `Connection test failed: ${testResult.error || 'Could not connect to IMAP server'}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save credentials to database and cache
|
||||
console.log(`Saving credentials for user: ${session.user.id}`);
|
||||
await saveUserEmailCredentials(session.user.id, email, credentials);
|
||||
console.log(`Email account successfully added for user ${session.user.id}`);
|
||||
|
||||
// Fetch the created account from the database
|
||||
const createdAccount = await prisma.mailCredentials.findFirst({
|
||||
where: { userId: session.user.id, email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
display_name: true,
|
||||
color: true,
|
||||
}
|
||||
});
|
||||
|
||||
// Invalidate all folder caches for this user/account
|
||||
await invalidateFolderCache(session.user.id, email, '*');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
account: createdAccount,
|
||||
message: 'Email account added successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding email account:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to add email account',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const accountId = searchParams.get('accountId');
|
||||
if (!accountId) {
|
||||
return NextResponse.json({ error: 'Missing accountId' }, { status: 400 });
|
||||
}
|
||||
// Find the account
|
||||
const account = await prisma.mailCredentials.findFirst({
|
||||
where: { id: accountId, userId: session.user.id },
|
||||
});
|
||||
if (!account) {
|
||||
return NextResponse.json({ error: 'Account not found' }, { status: 404 });
|
||||
}
|
||||
// Delete from database
|
||||
await prisma.mailCredentials.delete({ where: { id: accountId } });
|
||||
// Invalidate cache
|
||||
await invalidateFolderCache(session.user.id, account.email, '*');
|
||||
return NextResponse.json({ success: true, message: 'Account deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting account:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete account', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { accountId, newPassword, display_name, color } = body;
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if at least one of the fields is provided
|
||||
if (!newPassword && !display_name && !color) {
|
||||
return NextResponse.json(
|
||||
{ error: 'At least one field to update is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the account belongs to the user
|
||||
const account = await prisma.mailCredentials.findFirst({
|
||||
where: {
|
||||
id: accountId,
|
||||
userId: session.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build update data object
|
||||
const updateData: any = {};
|
||||
|
||||
// Add password if provided
|
||||
if (newPassword) {
|
||||
updateData.password = newPassword;
|
||||
}
|
||||
|
||||
// Add display_name if provided
|
||||
if (display_name !== undefined) {
|
||||
updateData.display_name = display_name;
|
||||
}
|
||||
|
||||
// Add color if provided
|
||||
if (color) {
|
||||
updateData.color = color;
|
||||
}
|
||||
|
||||
// Update the account
|
||||
await prisma.mailCredentials.update({
|
||||
where: { id: accountId },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Account updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating account:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to update account',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddAccount = async (accountData: AccountData) => {
|
||||
// ... account creation logic ...
|
||||
// setAccounts(prev => [...prev, newAccount]);
|
||||
// setVisibleFolders(prev => ({
|
||||
// ...prev,
|
||||
// [newAccount.id]: newAccount.folders
|
||||
// }));
|
||||
};
|
||||
182
app/api/courrier/debug-account/route.ts
Normal file
182
app/api/courrier/debug-account/route.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getCachedEmailCredentials, getCachedImapSession } from '@/lib/redis';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getMailboxes } from '@/lib/services/email-service';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
|
||||
export async function GET() {
|
||||
// Verify auth
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const debugData: any = {
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
redis: {
|
||||
emailCredentials: null,
|
||||
session: null
|
||||
},
|
||||
database: {
|
||||
accounts: [],
|
||||
schema: null
|
||||
},
|
||||
imap: {
|
||||
connectionAttempt: false,
|
||||
connected: false,
|
||||
folders: []
|
||||
}
|
||||
};
|
||||
|
||||
// Check Redis cache for credentials
|
||||
try {
|
||||
const credentials = await getCachedEmailCredentials(userId);
|
||||
if (credentials) {
|
||||
debugData.redis.emailCredentials = {
|
||||
found: true,
|
||||
email: credentials.email,
|
||||
host: credentials.host,
|
||||
port: credentials.port,
|
||||
hasPassword: !!credentials.password,
|
||||
hasSmtp: !!credentials.smtp_host
|
||||
};
|
||||
} else {
|
||||
debugData.redis.emailCredentials = { found: false };
|
||||
}
|
||||
} catch (e) {
|
||||
debugData.redis.emailCredentials = {
|
||||
error: e instanceof Error ? e.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
|
||||
// Check Redis for session data (which contains folders)
|
||||
try {
|
||||
const sessionData = await getCachedImapSession(userId);
|
||||
if (sessionData) {
|
||||
debugData.redis.session = {
|
||||
found: true,
|
||||
lastActive: new Date(sessionData.lastActive).toISOString(),
|
||||
hasFolders: !!sessionData.mailboxes,
|
||||
folderCount: sessionData.mailboxes?.length || 0,
|
||||
folders: sessionData.mailboxes || []
|
||||
};
|
||||
} else {
|
||||
debugData.redis.session = { found: false };
|
||||
}
|
||||
} catch (e) {
|
||||
debugData.redis.session = {
|
||||
error: e instanceof Error ? e.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
|
||||
// Try to get database schema information to help diagnose issues
|
||||
try {
|
||||
const schemaInfo = await prisma.$queryRaw`
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'MailCredentials'
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
debugData.database.schema = schemaInfo;
|
||||
} catch (e) {
|
||||
debugData.database.schemaError = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
|
||||
// Check database for accounts
|
||||
try {
|
||||
const accounts = await prisma.mailCredentials.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
host: true,
|
||||
port: true
|
||||
}
|
||||
});
|
||||
|
||||
// Also try to get additional fields from raw query
|
||||
const accountsWithMetadata = await Promise.all(accounts.map(async (account) => {
|
||||
try {
|
||||
const rawAccount = await prisma.$queryRaw`
|
||||
SELECT display_name, color, smtp_host, smtp_port, smtp_secure, secure
|
||||
FROM "MailCredentials"
|
||||
WHERE id = ${account.id}
|
||||
`;
|
||||
|
||||
const metadata = Array.isArray(rawAccount) && rawAccount.length > 0
|
||||
? rawAccount[0]
|
||||
: {};
|
||||
|
||||
return {
|
||||
...account,
|
||||
display_name: metadata.display_name,
|
||||
color: metadata.color,
|
||||
smtp_host: metadata.smtp_host,
|
||||
smtp_port: metadata.smtp_port,
|
||||
smtp_secure: metadata.smtp_secure,
|
||||
secure: metadata.secure
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
...account,
|
||||
_queryError: e instanceof Error ? e.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
debugData.database.accounts = accountsWithMetadata;
|
||||
debugData.database.accountCount = accounts.length;
|
||||
} catch (e) {
|
||||
debugData.database.error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
|
||||
// Try to get IMAP folders for the main account
|
||||
if (debugData.redis.emailCredentials?.found || debugData.database.accountCount > 0) {
|
||||
try {
|
||||
debugData.imap.connectionAttempt = true;
|
||||
|
||||
// Use cached credentials
|
||||
const credentials = await getCachedEmailCredentials(userId);
|
||||
|
||||
if (credentials && credentials.email && credentials.password) {
|
||||
const client = new ImapFlow({
|
||||
host: credentials.host,
|
||||
port: credentials.port,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: credentials.email,
|
||||
pass: credentials.password,
|
||||
},
|
||||
logger: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
debugData.imap.connected = true;
|
||||
|
||||
// Get folders
|
||||
const folders = await getMailboxes(client);
|
||||
debugData.imap.folders = folders;
|
||||
|
||||
// Close connection
|
||||
await client.logout();
|
||||
} else {
|
||||
debugData.imap.error = "No valid credentials found";
|
||||
}
|
||||
} catch (e) {
|
||||
debugData.imap.error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(debugData);
|
||||
}
|
||||
133
app/api/courrier/delete/route.ts
Normal file
133
app/api/courrier/delete/route.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getImapConnection } from '@/lib/services/email-service';
|
||||
import { invalidateFolderCache } from '@/lib/redis';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not authenticated" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract request body
|
||||
const body = await request.json();
|
||||
const { emailIds, folder, accountId } = body;
|
||||
|
||||
// Validate required parameters
|
||||
if (!emailIds || !Array.isArray(emailIds) || emailIds.length === 0) {
|
||||
console.error('[DELETE API] Missing or invalid emailIds parameter:', emailIds);
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or invalid emailIds parameter" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!folder) {
|
||||
console.error('[DELETE API] Missing folder parameter');
|
||||
return NextResponse.json(
|
||||
{ error: "Missing folder parameter" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract account ID from folder name if present and none was explicitly provided
|
||||
const folderAccountId = folder.includes(':') ? folder.split(':')[0] : undefined;
|
||||
|
||||
// Use the most specific account ID available
|
||||
const effectiveAccountId = folderAccountId || accountId || 'default';
|
||||
|
||||
// Normalize folder name by removing account prefix if present
|
||||
const normalizedFolder = folder.includes(':') ? folder.split(':')[1] : folder;
|
||||
|
||||
console.log(`[DELETE API] Deleting ${emailIds.length} emails from folder ${normalizedFolder}, account ${effectiveAccountId}`);
|
||||
|
||||
// Get IMAP connection
|
||||
const client = await getImapConnection(session.user.id, effectiveAccountId);
|
||||
|
||||
try {
|
||||
// Open the mailbox
|
||||
await client.mailboxOpen(normalizedFolder);
|
||||
|
||||
// Check if we're already in the trash folder
|
||||
const inTrash = normalizedFolder.toLowerCase() === 'trash' ||
|
||||
normalizedFolder.toLowerCase() === 'bin' ||
|
||||
normalizedFolder.toLowerCase() === 'deleted';
|
||||
|
||||
if (inTrash) {
|
||||
// If we're in trash, mark as deleted
|
||||
console.log(`[DELETE API] In trash folder, marking emails as deleted: ${emailIds.join(', ')}`);
|
||||
|
||||
// Mark messages as deleted
|
||||
for (const emailId of emailIds) {
|
||||
await client.messageFlagsAdd(emailId, ['\\Deleted']);
|
||||
}
|
||||
} else {
|
||||
// If not in trash, move to trash
|
||||
console.log(`[DELETE API] Moving emails to trash: ${emailIds.join(', ')}`);
|
||||
|
||||
// Try to find the trash folder
|
||||
const mailboxes = await client.list();
|
||||
let trashFolder = 'Trash';
|
||||
|
||||
// Look for common trash folder names
|
||||
const trashFolderNames = ['Trash', 'TRASH', 'Bin', 'Deleted', 'Deleted Items'];
|
||||
for (const folder of mailboxes) {
|
||||
if (trashFolderNames.includes(folder.name) ||
|
||||
trashFolderNames.some(name => folder.name.toLowerCase().includes(name.toLowerCase()))) {
|
||||
trashFolder = folder.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Move messages to trash
|
||||
for (const emailId of emailIds) {
|
||||
try {
|
||||
// Convert the emailId to a number if it's a string
|
||||
const uid = typeof emailId === 'string' ? parseInt(emailId, 10) : emailId;
|
||||
|
||||
// Debug logging for troubleshooting
|
||||
console.log(`[DELETE API] Moving email with UID ${uid} to trash folder "${trashFolder}"`);
|
||||
|
||||
// Use the correct syntax for messageMove method
|
||||
await client.messageMove(uid.toString(), trashFolder, { uid: true });
|
||||
|
||||
console.log(`[DELETE API] Successfully moved email ${uid} to trash`);
|
||||
} catch (moveError) {
|
||||
console.error(`[DELETE API] Error moving email ${emailId} to trash:`, moveError);
|
||||
// Continue with other emails even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate cache for source folder
|
||||
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
|
||||
|
||||
// Also invalidate trash folder cache
|
||||
await invalidateFolderCache(session.user.id, effectiveAccountId, 'Trash');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${emailIds.length} email(s) deleted successfully`
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
// Always close the mailbox
|
||||
await client.mailboxClose();
|
||||
} catch (error) {
|
||||
console.error('Error closing mailbox:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DELETE API] Error processing delete request:', error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete emails", details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
app/api/courrier/emails/route.ts
Normal file
88
app/api/courrier/emails/route.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getEmails } from '@/lib/services/email-service';
|
||||
import {
|
||||
getCachedEmailList,
|
||||
cacheEmailList,
|
||||
invalidateFolderCache
|
||||
} from '@/lib/redis';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not authenticated" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract query parameters
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const perPage = parseInt(searchParams.get("perPage") || "20");
|
||||
const folder = searchParams.get("folder") || "INBOX";
|
||||
const searchQuery = searchParams.get("search") || "";
|
||||
const accountId = searchParams.get("accountId") || "";
|
||||
const checkOnly = searchParams.get("checkOnly") === "true";
|
||||
|
||||
// Log exact parameters received by the API
|
||||
console.log(`[API/emails] Received request with: folder=${folder}, accountId=${accountId}, page=${page}, checkOnly=${checkOnly}`);
|
||||
|
||||
// Parameter normalization
|
||||
// If folder contains an account prefix, extract it but DO NOT use it
|
||||
// Always prioritize the explicit accountId parameter
|
||||
let normalizedFolder = folder;
|
||||
let effectiveAccountId = accountId || 'default';
|
||||
|
||||
if (folder.includes(':')) {
|
||||
const parts = folder.split(':');
|
||||
normalizedFolder = parts[1];
|
||||
|
||||
console.log(`[API/emails] Folder has prefix, normalized to ${normalizedFolder}`);
|
||||
}
|
||||
|
||||
console.log(`[API/emails] Using normalized parameters: folder=${normalizedFolder}, accountId=${effectiveAccountId}`);
|
||||
|
||||
// Try to get from Redis cache first, but only if it's not a search query and not checkOnly
|
||||
if (!searchQuery && !checkOnly) {
|
||||
console.log(`[API/emails] Checking Redis cache for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
|
||||
const cachedEmails = await getCachedEmailList(
|
||||
session.user.id,
|
||||
effectiveAccountId,
|
||||
normalizedFolder,
|
||||
page,
|
||||
perPage
|
||||
);
|
||||
if (cachedEmails) {
|
||||
console.log(`[API/emails] Using Redis cached emails for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
|
||||
return NextResponse.json(cachedEmails);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[API/emails] Redis cache miss for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}, fetching emails from IMAP`);
|
||||
|
||||
// Use the email service to fetch emails
|
||||
const emailsResult = await getEmails(
|
||||
session.user.id,
|
||||
normalizedFolder,
|
||||
page,
|
||||
perPage,
|
||||
effectiveAccountId,
|
||||
checkOnly
|
||||
);
|
||||
|
||||
console.log(`[API/emails] Successfully fetched ${emailsResult.emails.length} emails from IMAP for account ${effectiveAccountId}`);
|
||||
|
||||
// Return result
|
||||
return NextResponse.json(emailsResult);
|
||||
} catch (error: any) {
|
||||
console.error("[API/emails] Error fetching emails:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch emails", message: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
116
app/api/courrier/fix-folders/route.ts
Normal file
116
app/api/courrier/fix-folders/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getMailboxes } from '@/lib/services/email-service';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { cacheImapSession, getCachedImapSession } from '@/lib/redis';
|
||||
|
||||
export async function POST() {
|
||||
// Verify auth
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const results = {
|
||||
success: false,
|
||||
userId,
|
||||
accountsProcessed: 0,
|
||||
foldersFound: 0,
|
||||
accounts: [] as any[]
|
||||
};
|
||||
|
||||
try {
|
||||
// Get all accounts for this user
|
||||
const accounts = await prisma.mailCredentials.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
password: true,
|
||||
host: true,
|
||||
port: true
|
||||
}
|
||||
});
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'No email accounts found'
|
||||
});
|
||||
}
|
||||
|
||||
// Process each account
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
// Connect to IMAP server for this account
|
||||
const client = new ImapFlow({
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: account.email,
|
||||
pass: account.password,
|
||||
},
|
||||
logger: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
// Get folders for this account
|
||||
const folders = await getMailboxes(client);
|
||||
|
||||
// Store the results
|
||||
results.accounts.push({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
folderCount: folders.length,
|
||||
folders
|
||||
});
|
||||
|
||||
results.foldersFound += folders.length;
|
||||
results.accountsProcessed++;
|
||||
|
||||
// Get existing session data
|
||||
const existingSession = await getCachedImapSession(userId);
|
||||
|
||||
// Update the Redis cache with the folders
|
||||
await cacheImapSession(userId, {
|
||||
...(existingSession || { lastActive: Date.now() }),
|
||||
mailboxes: folders,
|
||||
lastVisit: Date.now()
|
||||
});
|
||||
|
||||
// Close connection
|
||||
await client.logout();
|
||||
} catch (error) {
|
||||
results.accounts.push({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.success = results.accountsProcessed > 0;
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fix folders',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import {
|
||||
saveUserEmailCredentials,
|
||||
getUserEmailCredentials,
|
||||
testEmailConnection
|
||||
} from '@/lib/services/email-service';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get credentials from request
|
||||
const { email, password, host, port } = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !password || !host || !port) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Test connection before saving
|
||||
const connectionSuccess = await testEmailConnection({
|
||||
email,
|
||||
password,
|
||||
host,
|
||||
port: parseInt(port)
|
||||
});
|
||||
|
||||
if (!connectionSuccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to email server. Please check your credentials.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save credentials in the database
|
||||
await saveUserEmailCredentials(session.user.id, {
|
||||
email,
|
||||
password,
|
||||
host,
|
||||
port: parseInt(port)
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error in login handler:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'An unexpected error occurred',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user credentials from database
|
||||
const credentials = await getUserEmailCredentials(session.user.id);
|
||||
|
||||
if (!credentials) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No stored credentials found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Return credentials without the password
|
||||
return NextResponse.json({
|
||||
email: credentials.email,
|
||||
host: credentials.host,
|
||||
port: credentials.port
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
149
app/api/courrier/microsoft/callback/route.ts
Normal file
149
app/api/courrier/microsoft/callback/route.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { exchangeCodeForTokens } from '@/lib/services/microsoft-oauth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { testEmailConnection, saveUserEmailCredentials } from '@/lib/services/email-service';
|
||||
import { invalidateFolderCache } from '@/lib/redis';
|
||||
import { cacheEmailCredentials } from '@/lib/redis';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { code, state } = body;
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate state parameter to prevent CSRF
|
||||
try {
|
||||
const decodedState = JSON.parse(Buffer.from(state, 'base64').toString());
|
||||
|
||||
// Check if state contains valid userId and is not expired (10 minutes)
|
||||
if (decodedState.userId !== session.user.id ||
|
||||
Date.now() - decodedState.timestamp > 10 * 60 * 1000) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired state parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid state parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code);
|
||||
|
||||
// Extract user email from session instead of generating a fake one
|
||||
// Use the logged-in user's email or a properly formatted address
|
||||
let userEmail = '';
|
||||
if (session.user?.email) {
|
||||
// Use the user's actual email if available
|
||||
userEmail = session.user.email;
|
||||
} else {
|
||||
// Fallback to a default format - don't add @microsoft.com
|
||||
userEmail = `unknown-user-${session.user.id}@outlook.com`;
|
||||
}
|
||||
|
||||
console.log(`Using email: ${userEmail} for Microsoft account`);
|
||||
|
||||
// Create credentials object for Microsoft account
|
||||
const credentials = {
|
||||
email: userEmail,
|
||||
// Password is empty for OAuth accounts - use a placeholder to meet database schema requirements
|
||||
password: 'microsoft-oauth2-account',
|
||||
// Use Microsoft's IMAP server for Outlook/Office365
|
||||
host: 'outlook.office365.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
|
||||
// OAuth specific fields
|
||||
useOAuth: true, // Make sure this is explicitly set
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
tokenExpiry: Date.now() + (tokens.expires_in * 1000),
|
||||
|
||||
// Optional fields
|
||||
display_name: `Microsoft (${userEmail})`,
|
||||
color: '#0078D4', // Microsoft blue
|
||||
|
||||
// SMTP settings for Microsoft
|
||||
smtp_host: 'smtp.office365.com',
|
||||
smtp_port: 587,
|
||||
smtp_secure: false
|
||||
};
|
||||
|
||||
// Log Microsoft authentication details
|
||||
console.log(`Microsoft OAuth credentials prepared for ${userEmail}:`, {
|
||||
useOAuth: credentials.useOAuth,
|
||||
host: credentials.host,
|
||||
hasAccessToken: !!credentials.accessToken,
|
||||
hasRefreshToken: !!credentials.refreshToken,
|
||||
tokenExpiry: new Date(credentials.tokenExpiry).toISOString()
|
||||
});
|
||||
|
||||
// Test connection before saving
|
||||
console.log(`Testing Microsoft OAuth connection for user ${session.user.id}`);
|
||||
const testResult = await testEmailConnection(credentials);
|
||||
|
||||
if (!testResult.imap) {
|
||||
return NextResponse.json(
|
||||
{ error: `Connection test failed: ${testResult.error || 'Could not connect to Microsoft IMAP server'}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save credentials to database and cache
|
||||
console.log(`Saving Microsoft account for user: ${session.user.id}`);
|
||||
await saveUserEmailCredentials(session.user.id, userEmail, credentials);
|
||||
|
||||
// Fetch the created account from the database
|
||||
const createdAccount = await prisma.mailCredentials.findFirst({
|
||||
where: { userId: session.user.id, email: userEmail },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
display_name: true,
|
||||
color: true,
|
||||
}
|
||||
});
|
||||
|
||||
// Invalidate any existing folder caches
|
||||
await invalidateFolderCache(session.user.id, userEmail, '*');
|
||||
|
||||
// First cache the credentials in Redis to ensure OAuth data is saved
|
||||
await cacheEmailCredentials(session.user.id, userEmail, credentials);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
account: createdAccount,
|
||||
message: 'Microsoft account added successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing Microsoft callback:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to process Microsoft authentication',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
app/api/courrier/microsoft/route.ts
Normal file
41
app/api/courrier/microsoft/route.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getMicrosoftAuthUrl } from '@/lib/services/microsoft-oauth';
|
||||
|
||||
// Endpoint to initiate Microsoft OAuth flow
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create a state parameter with the user's ID to prevent CSRF
|
||||
const state = Buffer.from(JSON.stringify({
|
||||
userId: session.user.id,
|
||||
timestamp: Date.now()
|
||||
})).toString('base64');
|
||||
|
||||
// Generate the authorization URL
|
||||
const authUrl = getMicrosoftAuthUrl(state);
|
||||
|
||||
return NextResponse.json({
|
||||
authUrl,
|
||||
state
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initiating Microsoft OAuth flow:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to initiate Microsoft OAuth flow',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/api/courrier/recache/route.ts
Normal file
49
app/api/courrier/recache/route.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { forceRecacheUserCredentials } from '@/lib/services/email-service';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get session to ensure user is authenticated
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || !session.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
// Force recache credentials
|
||||
const success = await forceRecacheUserCredentials(userId);
|
||||
|
||||
if (success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Credentials recached successfully',
|
||||
userId
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to recache credentials. Check server logs for details.',
|
||||
userId
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Recache API error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
69
app/api/courrier/refresh/route.ts
Normal file
69
app/api/courrier/refresh/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getEmails } from '@/lib/services/email-service';
|
||||
import { invalidateFolderCache } from '@/lib/redis';
|
||||
import { refreshEmailsInBackground } from '@/lib/services/prefetch-service';
|
||||
|
||||
/**
|
||||
* API endpoint to force refresh email data
|
||||
* This is useful when the user wants to manually refresh or
|
||||
* when the app detects that it's been a while since the last refresh
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not authenticated" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract folder and account ID from request body
|
||||
const { folder = 'INBOX', accountId } = await request.json();
|
||||
|
||||
// CRITICAL FIX: Proper folder and account ID handling
|
||||
let normalizedFolder: string;
|
||||
let effectiveAccountId: string;
|
||||
|
||||
if (folder.includes(':')) {
|
||||
// Extract parts if folder already has a prefix
|
||||
const parts = folder.split(':');
|
||||
const folderAccountId = parts[0];
|
||||
normalizedFolder = parts[1];
|
||||
|
||||
// If explicit accountId is provided, it takes precedence
|
||||
effectiveAccountId = accountId || folderAccountId;
|
||||
} else {
|
||||
// No prefix in folder name
|
||||
normalizedFolder = folder;
|
||||
effectiveAccountId = accountId || 'default';
|
||||
}
|
||||
|
||||
console.log(`[API] Refreshing folder=${normalizedFolder}, accountId=${effectiveAccountId}`);
|
||||
|
||||
// First invalidate the cache for this folder with the effective account ID
|
||||
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
|
||||
|
||||
// Then trigger a background refresh with explicit account ID
|
||||
refreshEmailsInBackground(session.user.id, normalizedFolder, 1, 20, effectiveAccountId);
|
||||
|
||||
// Also prefetch page 2 if this is the inbox
|
||||
if (normalizedFolder === 'INBOX') {
|
||||
refreshEmailsInBackground(session.user.id, normalizedFolder, 2, 20, effectiveAccountId);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Refresh scheduled for folder: ${folder}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error scheduling refresh:', error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to schedule refresh" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,14 @@ import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getEmails } from '@/lib/services/email-service';
|
||||
import {
|
||||
getCachedEmailList,
|
||||
cacheEmailList,
|
||||
invalidateFolderCache
|
||||
} from '@/lib/redis';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Simple in-memory cache (will be removed in a future update)
|
||||
interface EmailCacheEntry {
|
||||
@ -30,37 +38,67 @@ export async function GET(request: Request) {
|
||||
const perPage = parseInt(searchParams.get("perPage") || "20");
|
||||
const folder = searchParams.get("folder") || "INBOX";
|
||||
const searchQuery = searchParams.get("search") || "";
|
||||
const accountId = searchParams.get("accountId") || "";
|
||||
const checkOnly = searchParams.get("checkOnly") === "true";
|
||||
|
||||
// Check cache - temporary until we implement a proper server-side cache
|
||||
const cacheKey = `${session.user.id}:${folder}:${page}:${perPage}:${searchQuery}`;
|
||||
const now = Date.now();
|
||||
const cachedEmails = emailListCache[cacheKey];
|
||||
|
||||
if (cachedEmails && now - cachedEmails.timestamp < CACHE_TTL) {
|
||||
console.log(`Using cached emails for ${cacheKey}`);
|
||||
return NextResponse.json(cachedEmails.data);
|
||||
// CRITICAL FIX: Log exact parameters received by the API
|
||||
console.log(`[API] Received request with: folder=${folder}, accountId=${accountId}, page=${page}, checkOnly=${checkOnly}`);
|
||||
|
||||
// CRITICAL FIX: More robust parameter normalization
|
||||
// 1. If folder contains an account prefix, extract it but DO NOT use it
|
||||
// 2. Always prioritize the explicit accountId parameter
|
||||
let normalizedFolder = folder;
|
||||
let effectiveAccountId = accountId || 'default';
|
||||
|
||||
if (folder.includes(':')) {
|
||||
const parts = folder.split(':');
|
||||
const folderAccountId = parts[0];
|
||||
normalizedFolder = parts[1];
|
||||
|
||||
console.log(`[API] Folder has prefix (${folderAccountId}), normalized to ${normalizedFolder}`);
|
||||
// We intentionally DO NOT use folderAccountId here - the explicit accountId parameter takes precedence
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Enhanced logging for parameter resolution
|
||||
console.log(`[API] Using normalized parameters: folder=${normalizedFolder}, accountId=${effectiveAccountId}`);
|
||||
|
||||
// Try to get from Redis cache first, but only if it's not a search query and not checkOnly
|
||||
if (!searchQuery && !checkOnly) {
|
||||
// CRITICAL FIX: Use consistent cache key format with the correct account ID
|
||||
console.log(`[API] Checking Redis cache for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
|
||||
const cachedEmails = await getCachedEmailList(
|
||||
session.user.id,
|
||||
effectiveAccountId, // Use effective account ID for consistent cache key
|
||||
normalizedFolder, // Use normalized folder name without prefix
|
||||
page,
|
||||
perPage
|
||||
);
|
||||
if (cachedEmails) {
|
||||
console.log(`[API] Using Redis cached emails for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
|
||||
return NextResponse.json(cachedEmails);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Cache miss for ${cacheKey}, fetching emails`);
|
||||
console.log(`[API] Redis cache miss for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}, fetching emails from IMAP`);
|
||||
|
||||
// Use the email service to fetch emails
|
||||
// Use the email service to fetch emails with the normalized folder and effective account ID
|
||||
// CRITICAL FIX: Pass parameters in the correct order and with proper values
|
||||
const emailsResult = await getEmails(
|
||||
session.user.id,
|
||||
folder,
|
||||
page,
|
||||
perPage,
|
||||
searchQuery
|
||||
session.user.id, // userId
|
||||
normalizedFolder, // folder (without prefix)
|
||||
page, // page
|
||||
perPage, // perPage
|
||||
effectiveAccountId, // accountId
|
||||
checkOnly // checkOnly flag - only check for new emails without loading full content
|
||||
);
|
||||
|
||||
// Cache the results
|
||||
emailListCache[cacheKey] = {
|
||||
data: emailsResult,
|
||||
timestamp: now
|
||||
};
|
||||
// CRITICAL FIX: Log when emails are returned from IMAP
|
||||
console.log(`[API] Successfully fetched ${emailsResult.emails.length} emails from IMAP for account ${effectiveAccountId}`);
|
||||
|
||||
// The result is already cached in the getEmails function (if not checkOnly)
|
||||
return NextResponse.json(emailsResult);
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching emails:", error);
|
||||
console.error("[API] Error fetching emails:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch emails", message: error.message },
|
||||
{ status: 500 }
|
||||
@ -75,25 +113,33 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { emailId, folderName } = await request.json();
|
||||
const { emailId, folderName, accountId } = await request.json();
|
||||
|
||||
if (!emailId) {
|
||||
return NextResponse.json({ error: 'Missing emailId parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Invalidate cache entries for this folder or all folders if none specified
|
||||
const userId = session.user.id;
|
||||
Object.keys(emailListCache).forEach(key => {
|
||||
if (folderName) {
|
||||
if (key.includes(`${userId}:${folderName}`)) {
|
||||
delete emailListCache[key];
|
||||
}
|
||||
} else {
|
||||
if (key.startsWith(`${userId}:`)) {
|
||||
delete emailListCache[key];
|
||||
}
|
||||
// Use account ID or default if not provided
|
||||
const effectiveAccountId = accountId || 'default';
|
||||
|
||||
// Normalize folder name by removing account prefix if present
|
||||
const normalizedFolder = folderName && folderName.includes(':')
|
||||
? folderName.split(':')[1]
|
||||
: folderName;
|
||||
|
||||
// Log the cache invalidation operation
|
||||
console.log(`Invalidating cache for user ${session.user.id}, account ${effectiveAccountId}, folder ${normalizedFolder || 'all folders'}`);
|
||||
|
||||
// Invalidate Redis cache for the folder
|
||||
if (normalizedFolder) {
|
||||
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
|
||||
} else {
|
||||
// If no folder specified, invalidate all folders (using a wildcard pattern)
|
||||
const folders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk'];
|
||||
for (const folder of folders) {
|
||||
await invalidateFolderCache(session.user.id, effectiveAccountId, folder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
194
app/api/courrier/session/route.ts
Normal file
194
app/api/courrier/session/route.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getMailboxes } from '@/lib/services/email-service';
|
||||
import { getRedisClient } from '@/lib/redis';
|
||||
import { getImapConnection } from '@/lib/services/email-service';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
// Define extended MailCredentials type
|
||||
interface MailCredentials {
|
||||
id: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
secure?: boolean;
|
||||
smtp_host?: string | null;
|
||||
smtp_port?: number | null;
|
||||
smtp_secure?: boolean | null;
|
||||
display_name?: string | null;
|
||||
color?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Keep track of last prefetch time for each user
|
||||
const lastPrefetchMap = new Map<string, number>();
|
||||
const PREFETCH_COOLDOWN_MS = 30000; // 30 seconds cooldown between prefetches
|
||||
|
||||
// Cache TTL for folders in Redis (5 minutes)
|
||||
const FOLDERS_CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
// Redis key for folders cache
|
||||
const FOLDERS_CACHE_KEY = (userId: string, accountId: string) => `email:folders:${userId}:${accountId}`;
|
||||
|
||||
/**
|
||||
* This endpoint is called when the app initializes to check if the user has email credentials
|
||||
* and to start prefetching email data in the background if they do
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Get Redis connection first to ensure it's available
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
console.error('Redis connection failed');
|
||||
return NextResponse.json({ error: 'Redis connection failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get session with detailed logging
|
||||
console.log('Attempting to get server session...');
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
console.error('No session found');
|
||||
return NextResponse.json({
|
||||
authenticated: false,
|
||||
error: 'No session found'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
if (!session.user) {
|
||||
console.error('No user in session');
|
||||
return NextResponse.json({
|
||||
authenticated: false,
|
||||
error: 'No user in session'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
if (!session.user.id) {
|
||||
console.error('No user ID in session');
|
||||
return NextResponse.json({
|
||||
authenticated: false,
|
||||
error: 'No user ID in session'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
console.log('Session validated successfully:', {
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name
|
||||
});
|
||||
|
||||
// Get user with their accounts
|
||||
console.log('Fetching user with ID:', session.user.id);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: { mailCredentials: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error('User not found in database');
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
hasEmailCredentials: false,
|
||||
error: 'User not found in database'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all accounts for the user
|
||||
const accounts = (user.mailCredentials || []) as MailCredentials[];
|
||||
if (accounts.length === 0) {
|
||||
console.log('No email accounts found for user:', session.user.id);
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
hasEmailCredentials: false,
|
||||
accounts: [],
|
||||
message: 'No email accounts found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Found ${accounts.length} accounts for user:`, accounts.map(a => a.email));
|
||||
|
||||
// Fetch folders for each account
|
||||
const accountsWithFolders = await Promise.all(
|
||||
accounts.map(async (account: MailCredentials) => {
|
||||
const cacheKey = FOLDERS_CACHE_KEY(user.id, account.id);
|
||||
|
||||
try {
|
||||
// Try to get folders from Redis cache first
|
||||
const cachedFolders = await redis.get(cacheKey);
|
||||
if (cachedFolders) {
|
||||
console.log(`Using cached folders for account ${account.email}`);
|
||||
return {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
display_name: account.display_name,
|
||||
color: account.color,
|
||||
folders: JSON.parse(cachedFolders)
|
||||
};
|
||||
}
|
||||
|
||||
// If not in cache, fetch from IMAP
|
||||
console.log(`Fetching folders from IMAP for account ${account.email}`);
|
||||
const client = await getImapConnection(user.id, account.id);
|
||||
if (!client) {
|
||||
console.warn(`Failed to get IMAP connection for account ${account.email}`);
|
||||
return {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
display_name: account.display_name,
|
||||
color: account.color,
|
||||
folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']
|
||||
};
|
||||
}
|
||||
|
||||
const folders = await getMailboxes(client);
|
||||
console.log(`Fetched ${folders.length} folders for account ${account.email}`);
|
||||
|
||||
// Cache the folders in Redis
|
||||
await redis.set(
|
||||
cacheKey,
|
||||
JSON.stringify(folders),
|
||||
'EX',
|
||||
FOLDERS_CACHE_TTL
|
||||
);
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
display_name: account.display_name,
|
||||
color: account.color,
|
||||
folders
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching folders for account ${account.id}:`, error);
|
||||
return {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
display_name: account.display_name,
|
||||
color: account.color,
|
||||
folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
hasEmailCredentials: true,
|
||||
allAccounts: accountsWithFolders
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in session route:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
authenticated: false,
|
||||
error: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
137
app/api/courrier/test-connection/route.ts
Normal file
137
app/api/courrier/test-connection/route.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Parse request body
|
||||
const body = await request.json().catch(e => {
|
||||
console.error('Error parsing request body:', e);
|
||||
return {};
|
||||
});
|
||||
|
||||
// Log request but hide password
|
||||
console.log('Testing connection with:', {
|
||||
...body,
|
||||
password: body.password ? '***' : undefined
|
||||
});
|
||||
|
||||
const { email, password, host, port, secure = true } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !password || !host || !port) {
|
||||
const missing = [];
|
||||
if (!email) missing.push('email');
|
||||
if (!password) missing.push('password');
|
||||
if (!host) missing.push('host');
|
||||
if (!port) missing.push('port');
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Missing required fields: ${missing.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fix common hostname errors - strip http/https prefixes
|
||||
let cleanHost = host;
|
||||
if (cleanHost.startsWith('http://')) {
|
||||
cleanHost = cleanHost.substring(7);
|
||||
} else if (cleanHost.startsWith('https://')) {
|
||||
cleanHost = cleanHost.substring(8);
|
||||
}
|
||||
|
||||
console.log(`Testing IMAP connection to ${cleanHost}:${port} for ${email}`);
|
||||
|
||||
// Test IMAP connection
|
||||
const client = new ImapFlow({
|
||||
host: cleanHost,
|
||||
port: typeof port === 'string' ? parseInt(port) : port,
|
||||
secure: secure === true || secure === 'true',
|
||||
auth: {
|
||||
user: email,
|
||||
pass: password,
|
||||
},
|
||||
logger: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
},
|
||||
// Set timeout to prevent long waits
|
||||
connectionTimeout: 10000
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log(`IMAP connection successful for ${email}`);
|
||||
|
||||
// Try to list mailboxes
|
||||
const mailboxes = await client.list();
|
||||
const folderNames = mailboxes.map(mailbox => mailbox.path);
|
||||
console.log(`Found ${folderNames.length} folders:`, folderNames.slice(0, 5));
|
||||
|
||||
try {
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'IMAP connection successful',
|
||||
details: {
|
||||
host: cleanHost,
|
||||
port,
|
||||
folderCount: folderNames.length,
|
||||
sampleFolders: folderNames.slice(0, 5)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('IMAP connection test failed:', error);
|
||||
|
||||
let friendlyMessage = 'Connection failed';
|
||||
let errorDetails = '';
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorDetails = error.message;
|
||||
|
||||
if (error.message.includes('Invalid login') || error.message.includes('authentication failed')) {
|
||||
friendlyMessage = 'Invalid username or password';
|
||||
} else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) {
|
||||
friendlyMessage = 'Cannot connect to server - check host and port';
|
||||
} else if (error.message.includes('certificate')) {
|
||||
friendlyMessage = 'SSL/TLS certificate issue';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
friendlyMessage = 'Connection timed out';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: friendlyMessage,
|
||||
details: errorDetails,
|
||||
debug: {
|
||||
providedHost: host,
|
||||
cleanHost,
|
||||
port,
|
||||
secure
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing connection:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to test connection',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
240
app/api/courrier/unread-counts/route.ts
Normal file
240
app/api/courrier/unread-counts/route.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getImapConnection } from '@/lib/services/email-service';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getRedisClient } from '@/lib/redis';
|
||||
|
||||
// Cache TTL for unread counts (increased to 2 minutes for better performance)
|
||||
const UNREAD_COUNTS_CACHE_TTL = 120;
|
||||
// Key for unread counts cache
|
||||
const UNREAD_COUNTS_CACHE_KEY = (userId: string) => `email:unread:${userId}`;
|
||||
// Refresh lock key to prevent parallel refreshes
|
||||
const REFRESH_LOCK_KEY = (userId: string) => `email:unread-refresh:${userId}`;
|
||||
// Lock TTL to prevent stuck locks (30 seconds)
|
||||
const REFRESH_LOCK_TTL = 30;
|
||||
|
||||
/**
|
||||
* API route for fetching unread counts for email folders
|
||||
* Optimized with proper caching, connection reuse, and background refresh
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not authenticated" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const redis = getRedisClient();
|
||||
|
||||
// First try to get from cache
|
||||
const cachedCounts = await redis.get(UNREAD_COUNTS_CACHE_KEY(userId));
|
||||
if (cachedCounts) {
|
||||
// Use cached results if available
|
||||
console.log(`[UNREAD_API] Using cached unread counts for user ${userId}`);
|
||||
|
||||
// If the cache is about to expire, schedule a background refresh
|
||||
const ttl = await redis.ttl(UNREAD_COUNTS_CACHE_KEY(userId));
|
||||
if (ttl < UNREAD_COUNTS_CACHE_TTL / 2) {
|
||||
// Only refresh if not already refreshing (use a lock)
|
||||
const lockAcquired = await redis.set(
|
||||
REFRESH_LOCK_KEY(userId),
|
||||
Date.now().toString(),
|
||||
'EX',
|
||||
REFRESH_LOCK_TTL,
|
||||
'NX' // Set only if key doesn't exist
|
||||
);
|
||||
|
||||
if (lockAcquired) {
|
||||
console.log(`[UNREAD_API] Scheduling background refresh for user ${userId}`);
|
||||
// Use Promise to run in background
|
||||
setTimeout(() => {
|
||||
refreshUnreadCounts(userId, redis)
|
||||
.catch(err => console.error(`[UNREAD_API] Background refresh error: ${err}`))
|
||||
.finally(() => {
|
||||
// Release lock regardless of outcome
|
||||
redis.del(REFRESH_LOCK_KEY(userId)).catch(() => {});
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(JSON.parse(cachedCounts));
|
||||
}
|
||||
|
||||
console.log(`[UNREAD_API] Cache miss for user ${userId}, fetching unread counts`);
|
||||
|
||||
// Try to acquire lock to prevent parallel refreshes
|
||||
const lockAcquired = await redis.set(
|
||||
REFRESH_LOCK_KEY(userId),
|
||||
Date.now().toString(),
|
||||
'EX',
|
||||
REFRESH_LOCK_TTL,
|
||||
'NX' // Set only if key doesn't exist
|
||||
);
|
||||
|
||||
if (!lockAcquired) {
|
||||
console.log(`[UNREAD_API] Another process is refreshing unread counts for ${userId}`);
|
||||
|
||||
// Return empty counts with short cache time if we can't acquire lock
|
||||
// The next request will likely get cached data
|
||||
return NextResponse.json({ _status: 'pending_refresh' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch new counts
|
||||
const unreadCounts = await fetchUnreadCounts(userId);
|
||||
|
||||
// Save to cache with longer TTL (2 minutes)
|
||||
await redis.set(
|
||||
UNREAD_COUNTS_CACHE_KEY(userId),
|
||||
JSON.stringify(unreadCounts),
|
||||
'EX',
|
||||
UNREAD_COUNTS_CACHE_TTL
|
||||
);
|
||||
|
||||
return NextResponse.json(unreadCounts);
|
||||
} finally {
|
||||
// Always release lock
|
||||
await redis.del(REFRESH_LOCK_KEY(userId));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[UNREAD_API] Error fetching unread counts:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch unread counts", message: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Background refresh function to update cache without blocking the API response
|
||||
*/
|
||||
async function refreshUnreadCounts(userId: string, redis: any): Promise<void> {
|
||||
try {
|
||||
console.log(`[UNREAD_API] Background refresh started for user ${userId}`);
|
||||
const unreadCounts = await fetchUnreadCounts(userId);
|
||||
|
||||
// Save to cache
|
||||
await redis.set(
|
||||
UNREAD_COUNTS_CACHE_KEY(userId),
|
||||
JSON.stringify(unreadCounts),
|
||||
'EX',
|
||||
UNREAD_COUNTS_CACHE_TTL
|
||||
);
|
||||
|
||||
console.log(`[UNREAD_API] Background refresh completed for user ${userId}`);
|
||||
} catch (error) {
|
||||
console.error(`[UNREAD_API] Background refresh failed for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core function to fetch unread counts from IMAP
|
||||
*/
|
||||
async function fetchUnreadCounts(userId: string): Promise<Record<string, Record<string, number>>> {
|
||||
// Get all accounts from the database directly
|
||||
const accounts = await prisma.mailCredentials.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[UNREAD_API] Found ${accounts.length} accounts for user ${userId}`);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return { default: {} };
|
||||
}
|
||||
|
||||
// Mapping to hold the unread counts
|
||||
const unreadCounts: Record<string, Record<string, number>> = {};
|
||||
|
||||
// For each account, get the unread counts for standard folders
|
||||
for (const account of accounts) {
|
||||
const accountId = account.id;
|
||||
try {
|
||||
// Get IMAP connection for this account
|
||||
console.log(`[UNREAD_API] Processing account ${accountId} (${account.email})`);
|
||||
const client = await getImapConnection(userId, accountId);
|
||||
unreadCounts[accountId] = {};
|
||||
|
||||
// Standard folders to check
|
||||
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam', 'Archive', 'Sent Items', 'Archives', 'Notes', 'Éléments supprimés'];
|
||||
|
||||
// Get mailboxes for this account to check if folders exist
|
||||
const mailboxes = await client.list();
|
||||
const availableFolders = mailboxes.map(mb => mb.path);
|
||||
|
||||
// Check each standard folder if it exists
|
||||
for (const folder of standardFolders) {
|
||||
// Skip if folder doesn't exist in this account
|
||||
if (!availableFolders.includes(folder) &&
|
||||
!availableFolders.some(f => f.toLowerCase() === folder.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check folder status without opening it (more efficient)
|
||||
const status = await client.status(folder, { unseen: true });
|
||||
|
||||
if (status && typeof status.unseen === 'number') {
|
||||
// Store the unread count
|
||||
unreadCounts[accountId][folder] = status.unseen;
|
||||
|
||||
// Also store with prefixed version for consistency
|
||||
unreadCounts[accountId][`${accountId}:${folder}`] = status.unseen;
|
||||
|
||||
console.log(`[UNREAD_API] Account ${accountId}, folder ${folder}: ${status.unseen} unread`);
|
||||
}
|
||||
} catch (folderError) {
|
||||
console.error(`[UNREAD_API] Error getting unread count for ${accountId}:${folder}:`, folderError);
|
||||
// Continue to next folder even if this one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Don't close the connection - let the connection pool handle it
|
||||
} catch (accountError) {
|
||||
console.error(`[UNREAD_API] Error processing account ${accountId}:`, accountError);
|
||||
}
|
||||
}
|
||||
|
||||
return unreadCounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get all account IDs for a user
|
||||
*/
|
||||
async function getUserAccountIds(userId: string): Promise<string[]> {
|
||||
try {
|
||||
// Get credentials for all accounts from the email service
|
||||
// This is a simplified version - you should replace this with your actual logic
|
||||
// to retrieve the user's accounts
|
||||
|
||||
// First try the default account
|
||||
const defaultClient = await getImapConnection(userId, 'default');
|
||||
const accounts = ['default'];
|
||||
|
||||
try {
|
||||
// Try to get other accounts if they exist
|
||||
// This is just a placeholder - implement your actual account retrieval logic
|
||||
|
||||
// Close the default connection
|
||||
await defaultClient.logout();
|
||||
} catch (error) {
|
||||
console.error('[UNREAD_API] Error getting additional accounts:', error);
|
||||
}
|
||||
|
||||
return accounts;
|
||||
} catch (error) {
|
||||
console.error('[UNREAD_API] Error getting account IDs:', error);
|
||||
return ['default']; // Return at least the default account
|
||||
}
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get credentials from database
|
||||
const credentials = await prisma.mailCredentials.findUnique({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!credentials) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No mail credentials found. Please configure your email account.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the current folder from the request URL
|
||||
const url = new URL(request.url);
|
||||
const folder = url.searchParams.get('folder') || 'INBOX';
|
||||
|
||||
// Connect to IMAP server
|
||||
const client = new ImapFlow({
|
||||
host: credentials.host,
|
||||
port: credentials.port,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: credentials.email,
|
||||
pass: credentials.password,
|
||||
},
|
||||
logger: false,
|
||||
emitLogs: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.mailboxOpen(folder);
|
||||
|
||||
// Fetch the full email content
|
||||
const message = await client.fetchOne(params.id, {
|
||||
source: true,
|
||||
envelope: true,
|
||||
flags: true
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract email content
|
||||
const result = {
|
||||
id: message.uid.toString(),
|
||||
from: message.envelope.from[0].address,
|
||||
subject: message.envelope.subject || '(No subject)',
|
||||
date: message.envelope.date.toISOString(),
|
||||
read: message.flags.has('\\Seen'),
|
||||
starred: message.flags.has('\\Flagged'),
|
||||
folder: folder,
|
||||
body: message.source.toString(),
|
||||
to: message.envelope.to?.map(addr => addr.address).join(', ') || '',
|
||||
cc: message.envelope.cc?.map(addr => addr.address).join(', ') || '',
|
||||
bcc: message.envelope.bcc?.map(addr => addr.address).join(', ') || '',
|
||||
};
|
||||
|
||||
return NextResponse.json(result);
|
||||
} finally {
|
||||
try {
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
console.error('Error during logout:', e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching email:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,172 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import Imap from 'imap';
|
||||
|
||||
interface StoredCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
function getStoredCredentials(): StoredCredentials | null {
|
||||
const cookieStore = cookies();
|
||||
const credentialsCookie = cookieStore.get('imap_credentials');
|
||||
|
||||
if (!credentialsCookie?.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = JSON.parse(credentialsCookie.value);
|
||||
if (!credentials.email || !credentials.password || !credentials.host || !credentials.port) {
|
||||
return null;
|
||||
}
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { emailIds, action } = await request.json();
|
||||
|
||||
if (!emailIds || !Array.isArray(emailIds) || !action) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request parameters' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the current folder from the request URL
|
||||
const url = new URL(request.url);
|
||||
const folder = url.searchParams.get('folder') || 'INBOX';
|
||||
|
||||
// Get stored credentials
|
||||
const credentials = getStoredCredentials();
|
||||
if (!credentials) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No stored credentials found' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const imap = new Imap({
|
||||
user: credentials.email,
|
||||
password: credentials.password,
|
||||
host: credentials.host,
|
||||
port: credentials.port,
|
||||
tls: true,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
authTimeout: 30000,
|
||||
connTimeout: 30000
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('IMAP connection timeout');
|
||||
imap.end();
|
||||
resolve(NextResponse.json({ error: 'Connection timeout' }));
|
||||
}, 30000);
|
||||
|
||||
imap.once('error', (err: Error) => {
|
||||
console.error('IMAP error:', err);
|
||||
clearTimeout(timeout);
|
||||
resolve(NextResponse.json({ error: 'IMAP connection error' }));
|
||||
});
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.openBox(folder, false, (err, box) => {
|
||||
if (err) {
|
||||
console.error(`Error opening box ${folder}:`, err);
|
||||
clearTimeout(timeout);
|
||||
imap.end();
|
||||
resolve(NextResponse.json({ error: `Failed to open folder ${folder}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert string IDs to numbers
|
||||
const numericIds = emailIds.map(id => parseInt(id, 10));
|
||||
|
||||
// Process each email
|
||||
let processedCount = 0;
|
||||
const totalEmails = numericIds.length;
|
||||
|
||||
const processNextEmail = (index: number) => {
|
||||
if (index >= totalEmails) {
|
||||
clearTimeout(timeout);
|
||||
imap.end();
|
||||
resolve(NextResponse.json({ success: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const id = numericIds[index];
|
||||
const fetch = imap.fetch(id.toString(), {
|
||||
bodies: '',
|
||||
struct: true
|
||||
});
|
||||
|
||||
fetch.on('message', (msg) => {
|
||||
msg.once('attributes', (attrs) => {
|
||||
const uid = attrs.uid;
|
||||
if (!uid) {
|
||||
processedCount++;
|
||||
processNextEmail(index + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
imap.move(uid, 'Trash', (err) => {
|
||||
if (err) console.error('Error moving to trash:', err);
|
||||
processedCount++;
|
||||
processNextEmail(index + 1);
|
||||
});
|
||||
break;
|
||||
case 'mark-read':
|
||||
imap.addFlags(uid, ['\\Seen'], (err) => {
|
||||
if (err) console.error('Error marking as read:', err);
|
||||
processedCount++;
|
||||
processNextEmail(index + 1);
|
||||
});
|
||||
break;
|
||||
case 'mark-unread':
|
||||
imap.removeFlags(uid, ['\\Seen'], (err) => {
|
||||
if (err) console.error('Error marking as unread:', err);
|
||||
processedCount++;
|
||||
processNextEmail(index + 1);
|
||||
});
|
||||
break;
|
||||
case 'archive':
|
||||
imap.move(uid, 'Archive', (err) => {
|
||||
if (err) console.error('Error moving to archive:', err);
|
||||
processedCount++;
|
||||
processNextEmail(index + 1);
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
fetch.on('error', (err) => {
|
||||
console.error('Error fetching email:', err);
|
||||
processedCount++;
|
||||
processNextEmail(index + 1);
|
||||
});
|
||||
};
|
||||
|
||||
processNextEmail(0);
|
||||
});
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in bulk actions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to perform bulk action' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,154 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
console.log('Session in mail login:', session);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
console.error('No user ID in session');
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
console.log('Checking for user with ID:', session.user.id);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id }
|
||||
});
|
||||
|
||||
console.log('User found in database:', user);
|
||||
|
||||
if (!user) {
|
||||
console.error('User not found in database');
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email, password, host, port } = await request.json();
|
||||
|
||||
if (!email || !password || !host || !port) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Test IMAP connection
|
||||
const client = new ImapFlow({
|
||||
host: host,
|
||||
port: parseInt(port),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: email,
|
||||
pass: password,
|
||||
},
|
||||
logger: false,
|
||||
emitLogs: false,
|
||||
tls: {
|
||||
rejectUnauthorized: false // Allow self-signed certificates
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.mailboxOpen('INBOX');
|
||||
|
||||
// Store or update credentials in database
|
||||
await prisma.mailCredentials.upsert({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
},
|
||||
update: {
|
||||
email,
|
||||
password,
|
||||
host,
|
||||
port: parseInt(port)
|
||||
},
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
email,
|
||||
password,
|
||||
host,
|
||||
port: parseInt(port)
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Invalid login')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid login or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `IMAP connection error: ${error.message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to email server' },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
console.error('Error during logout:', e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in login handler:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = await prisma.mailCredentials.findUnique({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
host: true,
|
||||
port: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!credentials) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No stored credentials found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(credentials);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { getImapClient } from '@/lib/imap';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let client: ImapFlow | null = null;
|
||||
try {
|
||||
// Get the session and validate it
|
||||
const session = await getServerSession(authOptions);
|
||||
console.log('Session:', session); // Debug log
|
||||
|
||||
if (!session?.user?.id) {
|
||||
console.error('No session or user ID found');
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the request body
|
||||
const { emailId, isRead } = await request.json();
|
||||
console.log('Request body:', { emailId, isRead }); // Debug log
|
||||
|
||||
if (!emailId || typeof isRead !== 'boolean') {
|
||||
console.error('Invalid request parameters:', { emailId, isRead });
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request parameters' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the current folder from the request URL
|
||||
const url = new URL(request.url);
|
||||
const folder = url.searchParams.get('folder') || 'INBOX';
|
||||
console.log('Folder:', folder); // Debug log
|
||||
|
||||
try {
|
||||
// Initialize IMAP client with user credentials
|
||||
client = await getImapClient();
|
||||
if (!client) {
|
||||
console.error('Failed to initialize IMAP client');
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to initialize email client' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
await client.connect();
|
||||
await client.mailboxOpen(folder);
|
||||
|
||||
// Fetch the email to get its UID
|
||||
const message = await client.fetchOne(emailId.toString(), {
|
||||
uid: true,
|
||||
flags: true
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
console.error('Email not found:', emailId);
|
||||
return NextResponse.json(
|
||||
{ error: 'Email not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update the flags
|
||||
if (isRead) {
|
||||
await client.messageFlagsAdd(message.uid.toString(), ['\\Seen'], { uid: true });
|
||||
} else {
|
||||
await client.messageFlagsRemove(message.uid.toString(), ['\\Seen'], { uid: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
if (client) {
|
||||
try {
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
console.error('Error during logout:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking email as read:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to mark email as read' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* This route is deprecated. It redirects to the new courrier API endpoint.
|
||||
* @deprecated Use the /api/courrier endpoint instead
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
console.warn('Deprecated: /api/mail route is being used. Update your code to use /api/courrier instead.');
|
||||
|
||||
// Extract query parameters
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Redirect to the new API endpoint
|
||||
const redirectUrl = new URL('/api/courrier', url.origin);
|
||||
|
||||
// Copy all search parameters
|
||||
url.searchParams.forEach((value, key) => {
|
||||
redirectUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
return NextResponse.redirect(redirectUrl.toString());
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* This route is deprecated. It redirects to the new courrier API endpoint.
|
||||
* @deprecated Use the /api/courrier/send endpoint instead
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
console.warn('Deprecated: /api/mail/send route is being used. Update your code to use /api/courrier/send instead.');
|
||||
|
||||
try {
|
||||
// Clone the request body
|
||||
const body = await request.json();
|
||||
|
||||
// Make a new request to the courrier API
|
||||
const newRequest = new Request(new URL('/api/courrier/send', request.url).toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// Forward the request
|
||||
const response = await fetch(newRequest);
|
||||
const data = await response.json();
|
||||
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
console.error('Error forwarding to courrier/send:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send email' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import Imap from 'imap';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { email, password, host, port } = await request.json();
|
||||
|
||||
if (!email || !password || !host || !port) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const imapConfig = {
|
||||
user: email,
|
||||
password,
|
||||
host,
|
||||
port: parseInt(port),
|
||||
tls: true,
|
||||
authTimeout: 10000,
|
||||
connTimeout: 10000,
|
||||
debug: (info: string) => console.log('IMAP Debug:', info)
|
||||
};
|
||||
|
||||
console.log('Testing IMAP connection with config:', {
|
||||
...imapConfig,
|
||||
password: '***',
|
||||
email
|
||||
});
|
||||
|
||||
const imap = new Imap(imapConfig);
|
||||
|
||||
const connectPromise = new Promise((resolve, reject) => {
|
||||
imap.once('ready', () => {
|
||||
imap.end();
|
||||
resolve(true);
|
||||
});
|
||||
imap.once('error', (err: Error) => {
|
||||
imap.end();
|
||||
reject(err);
|
||||
});
|
||||
imap.connect();
|
||||
});
|
||||
|
||||
await connectPromise;
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error testing connection:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to connect to email server' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,72 +1,72 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { simpleParser, AddressObject } from 'mailparser';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseEmail } from '@/lib/server/email-parser';
|
||||
import { sanitizeHtml } from '@/lib/utils/dom-purify-config';
|
||||
|
||||
function getEmailAddress(address: AddressObject | AddressObject[] | undefined): string | null {
|
||||
if (!address) return null;
|
||||
if (Array.isArray(address)) {
|
||||
return address.map(a => a.text).join(', ');
|
||||
}
|
||||
return address.text;
|
||||
interface EmailAddress {
|
||||
name?: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
// Clean up the HTML to make it safe but preserve styles
|
||||
function processHtml(html: string | null): string | null {
|
||||
if (!html) return null;
|
||||
// Helper to extract email addresses from mailparser Address objects
|
||||
function getEmailAddresses(addresses: any): EmailAddress[] {
|
||||
if (!addresses) return [];
|
||||
|
||||
try {
|
||||
// Make the content display well in the email context
|
||||
return html
|
||||
// Fix self-closing tags that might break React
|
||||
.replace(/<(br|hr|img|input|link|meta|area|base|col|embed|keygen|param|source|track|wbr)([^>]*)>/gi, '<$1$2 />')
|
||||
// Keep style tags but ensure they're closed properly
|
||||
.replace(/<style([^>]*)>([\s\S]*?)<\/style>/gi, (match) => {
|
||||
// Just return the matched style tag as-is
|
||||
return match;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing HTML:', error);
|
||||
return html;
|
||||
// Handle various address formats
|
||||
if (Array.isArray(addresses)) {
|
||||
return addresses.map(addr => ({
|
||||
name: addr.name || undefined,
|
||||
address: addr.address
|
||||
}));
|
||||
}
|
||||
|
||||
if (typeof addresses === 'object') {
|
||||
const result: EmailAddress[] = [];
|
||||
// Handle mailparser format with text, html, value properties
|
||||
if (addresses.value) {
|
||||
addresses.value.forEach((addr: any) => {
|
||||
result.push({
|
||||
name: addr.name || undefined,
|
||||
address: addr.address
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle direct object with address property
|
||||
if (addresses.address) {
|
||||
return [{
|
||||
name: addresses.name || undefined,
|
||||
address: addresses.address
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
if (!body.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email content' },
|
||||
{ error: 'Missing email content' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = await simpleParser(email);
|
||||
const parsedEmail = await parseEmail(body.email);
|
||||
|
||||
// Process the HTML to preserve styling but make it safe
|
||||
// Handle the case where parsed.html could be a boolean
|
||||
const processedHtml = typeof parsed.html === 'string' ? processHtml(parsed.html) : null;
|
||||
// Process HTML content if available
|
||||
if (parsedEmail.html) {
|
||||
parsedEmail.html = sanitizeHtml(parsedEmail.html);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
subject: parsed.subject || null,
|
||||
from: getEmailAddress(parsed.from),
|
||||
to: getEmailAddress(parsed.to),
|
||||
cc: getEmailAddress(parsed.cc),
|
||||
bcc: getEmailAddress(parsed.bcc),
|
||||
date: parsed.date || null,
|
||||
html: processedHtml,
|
||||
text: parsed.textAsHtml || parsed.text || null,
|
||||
attachments: parsed.attachments?.map(att => ({
|
||||
filename: att.filename,
|
||||
contentType: att.contentType,
|
||||
size: att.size
|
||||
})) || [],
|
||||
headers: parsed.headers || {}
|
||||
});
|
||||
return NextResponse.json(parsedEmail);
|
||||
} catch (error) {
|
||||
console.error('Error parsing email:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to parse email' },
|
||||
{ error: 'Failed to parse email', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
168
app/api/proxy/[...path]/route.ts
Normal file
168
app/api/proxy/[...path]/route.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||
|
||||
// Map of service prefixes to their base URLs
|
||||
const SERVICE_URLS: Record<string, string> = {
|
||||
'parole': process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '',
|
||||
'alma': process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || '',
|
||||
'crm': process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || '',
|
||||
'vision': process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || '',
|
||||
'showcase': process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || '',
|
||||
'agilite': process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || '',
|
||||
'dossiers': process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || '',
|
||||
'the-message': process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || '',
|
||||
'qg': process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || '',
|
||||
'design': process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '',
|
||||
'artlab': process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || ''
|
||||
};
|
||||
|
||||
// Check if a service is Rocket.Chat (they require special authentication)
|
||||
function isRocketChat(serviceName: string): boolean {
|
||||
return serviceName === 'parole'; // Assuming 'parole' is your Rocket.Chat service
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: { path: string[] } }
|
||||
) {
|
||||
// Get the service prefix (first part of the path)
|
||||
const paramsObj = await Promise.resolve(context.params);
|
||||
const pathArray = await Promise.resolve(paramsObj.path);
|
||||
|
||||
const serviceName = pathArray[0];
|
||||
const restOfPath = pathArray.slice(1).join('/');
|
||||
|
||||
// Get the base URL for this service
|
||||
const baseUrl = SERVICE_URLS[serviceName];
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({ error: 'Service not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get the user's session
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract search parameters
|
||||
const searchParams = new URL(request.url).searchParams.toString();
|
||||
const targetUrl = `${baseUrl}/${restOfPath}${searchParams ? `?${searchParams}` : ''}`;
|
||||
|
||||
// Prepare headers based on the service type
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (isRocketChat(serviceName)) {
|
||||
// For Rocket.Chat, use their specific authentication headers
|
||||
if (session.rocketChatToken && session.rocketChatUserId) {
|
||||
console.log('Using Rocket.Chat specific authentication');
|
||||
headers['X-Auth-Token'] = session.rocketChatToken;
|
||||
headers['X-User-Id'] = session.rocketChatUserId;
|
||||
} else {
|
||||
console.warn('Rocket.Chat tokens not available in session');
|
||||
// Still try with standard authorization if available
|
||||
if (session.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard OAuth Bearer token for other services
|
||||
if (session.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add other common headers
|
||||
headers['Accept'] = 'application/json, text/html, */*';
|
||||
|
||||
// Forward the request to the target service with the authentication headers
|
||||
const response = await fetch(targetUrl, { headers });
|
||||
|
||||
// Get response data
|
||||
const data = await response.arrayBuffer();
|
||||
|
||||
// Create response with the same status and headers
|
||||
const newResponse = new NextResponse(data, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream',
|
||||
}
|
||||
});
|
||||
|
||||
return newResponse;
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: { path: string[] } }
|
||||
) {
|
||||
// Get the service prefix (first part of the path)
|
||||
const paramsObj = await Promise.resolve(context.params);
|
||||
const pathArray = await Promise.resolve(paramsObj.path);
|
||||
|
||||
const serviceName = pathArray[0];
|
||||
const restOfPath = pathArray.slice(1).join('/');
|
||||
|
||||
const baseUrl = SERVICE_URLS[serviceName];
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({ error: 'Service not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = new URL(request.url).searchParams.toString();
|
||||
const targetUrl = `${baseUrl}/${restOfPath}${searchParams ? `?${searchParams}` : ''}`;
|
||||
|
||||
// Get the request body
|
||||
const body = await request.arrayBuffer();
|
||||
|
||||
// Prepare headers based on the service type
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': request.headers.get('Content-Type') || 'application/json',
|
||||
};
|
||||
|
||||
if (isRocketChat(serviceName)) {
|
||||
// For Rocket.Chat, use their specific authentication headers
|
||||
if (session.rocketChatToken && session.rocketChatUserId) {
|
||||
headers['X-Auth-Token'] = session.rocketChatToken;
|
||||
headers['X-User-Id'] = session.rocketChatUserId;
|
||||
} else if (session.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
} else {
|
||||
// Standard OAuth Bearer token for other services
|
||||
if (session.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: body
|
||||
});
|
||||
|
||||
const data = await response.arrayBuffer();
|
||||
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream',
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
24
app/api/redis/status/route.ts
Normal file
24
app/api/redis/status/route.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getRedisStatus } from '@/lib/redis';
|
||||
|
||||
/**
|
||||
* API route to check Redis connection status
|
||||
* Used for monitoring and debugging
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const status = await getRedisStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
ready: status.status === 'connected',
|
||||
status: status.status,
|
||||
ping: status.ping,
|
||||
error: status.error
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
ready: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
72
app/api/test-rocket/route.ts
Normal file
72
app/api/test-rocket/route.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check if we have the required environment variables
|
||||
const token = process.env.ROCKET_CHAT_TOKEN;
|
||||
const userId = process.env.ROCKET_CHAT_USER_ID;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
|
||||
|
||||
if (!token || !userId) {
|
||||
return NextResponse.json({
|
||||
error: 'Missing Rocket.Chat admin credentials',
|
||||
hasToken: !!token,
|
||||
hasUserId: !!userId
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({
|
||||
error: 'Missing Rocket.Chat base URL',
|
||||
iframeUrl: process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
// Test a simple API call to verify credentials
|
||||
const adminHeaders = {
|
||||
'X-Auth-Token': token,
|
||||
'X-User-Id': userId,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Get server info (public endpoint that still requires admin auth)
|
||||
const infoResponse = await fetch(`${baseUrl}/api/v1/info`, {
|
||||
method: 'GET',
|
||||
headers: adminHeaders
|
||||
});
|
||||
|
||||
if (!infoResponse.ok) {
|
||||
return NextResponse.json({
|
||||
error: 'Failed to connect to Rocket.Chat API',
|
||||
status: infoResponse.status,
|
||||
statusText: infoResponse.statusText,
|
||||
baseUrl
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
const infoData = await infoResponse.json();
|
||||
|
||||
// Try to list users (needs admin permissions)
|
||||
const usersResponse = await fetch(`${baseUrl}/api/v1/users.list?count=5`, {
|
||||
method: 'GET',
|
||||
headers: adminHeaders
|
||||
});
|
||||
|
||||
const usersResult = await usersResponse.json();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
serverInfo: {
|
||||
version: infoData.version,
|
||||
serverRunning: infoData.success
|
||||
},
|
||||
usersCount: usersResult.users?.length || 0,
|
||||
baseUrl
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: 'Error testing Rocket.Chat connection',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { getKeycloakAdminClient } from "@/lib/keycloak";
|
||||
import { RoleRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@ -14,21 +12,11 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId } = params;
|
||||
const kcAdminClient = await getKeycloakAdminClient();
|
||||
|
||||
// Get all available roles
|
||||
const availableRoles = await kcAdminClient.roles.find();
|
||||
|
||||
// Get user's current roles
|
||||
const userRoles = await kcAdminClient.users.listRoleMappings({
|
||||
id: userId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
availableRoles,
|
||||
userRoles,
|
||||
});
|
||||
// Keycloak functionality has been removed
|
||||
return NextResponse.json(
|
||||
{ error: "This functionality is not available" },
|
||||
{ status: 501 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error fetching roles:", error);
|
||||
return NextResponse.json(
|
||||
@ -48,46 +36,11 @@ export async function PUT(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId } = params;
|
||||
const { roles } = await request.json();
|
||||
const kcAdminClient = await getKeycloakAdminClient();
|
||||
|
||||
// Get all available roles
|
||||
const availableRoles = await kcAdminClient.roles.find();
|
||||
|
||||
// Get current user roles
|
||||
const currentRoles = await kcAdminClient.users.listRoleMappings({
|
||||
id: userId,
|
||||
});
|
||||
|
||||
// Find roles to add and remove
|
||||
const rolesToAdd = roles.filter(
|
||||
(role: string) => !currentRoles.realmMappings?.some((r: RoleRepresentation) => r.name === role)
|
||||
// Keycloak functionality has been removed
|
||||
return NextResponse.json(
|
||||
{ error: "This functionality is not available" },
|
||||
{ status: 501 }
|
||||
);
|
||||
const rolesToRemove = currentRoles.realmMappings?.filter(
|
||||
(role: RoleRepresentation) => !roles.includes(role.name)
|
||||
);
|
||||
|
||||
// Add new roles
|
||||
for (const roleName of rolesToAdd) {
|
||||
const role = availableRoles.find((r: RoleRepresentation) => r.name === roleName);
|
||||
if (role) {
|
||||
await kcAdminClient.users.addRealmRoleMappings({
|
||||
id: userId,
|
||||
roles: [role],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old roles
|
||||
if (rolesToRemove && rolesToRemove.length > 0) {
|
||||
await kcAdminClient.users.delRealmRoleMappings({
|
||||
id: userId,
|
||||
roles: rolesToRemove,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating roles:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_LEARN_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function ArtlabPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -10,12 +10,15 @@ export default async function ArtlabPage() {
|
||||
redirect("/signin");
|
||||
}
|
||||
|
||||
// Get the design URL from environment variable - intentionally shares URL with design section
|
||||
const designIframeUrl = process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '';
|
||||
|
||||
return (
|
||||
<main className="w-full h-screen bg-black">
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_ARTLAB_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
src={designIframeUrl}
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_CALCULATION_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function ChapitrePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function ChapitrePage() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_CHAPTER_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
114
app/components/debug/EmailDebug.tsx
Normal file
114
app/components/debug/EmailDebug.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { AlertCircle, Bug, RefreshCw } from 'lucide-react';
|
||||
|
||||
export function EmailDebug() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDebug = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/courrier/debug-account');
|
||||
const data = await response.json();
|
||||
console.log('Account Debug Data:', data);
|
||||
|
||||
// Show toast with basic info
|
||||
toast({
|
||||
title: "Debug Information",
|
||||
description: `Found ${data.database.accountCount || 0} accounts and ${data.redis.session?.folderCount || 0} folders`,
|
||||
duration: 5000
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Debug error:', error);
|
||||
toast({
|
||||
title: "Debug Error",
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: "destructive",
|
||||
duration: 5000
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFixFolders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
toast({
|
||||
title: "Fixing Folders",
|
||||
description: "Connecting to IMAP server to refresh folders...",
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
const response = await fetch('/api/courrier/fix-folders', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Fix Folders Result:', data);
|
||||
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: "Folders Updated",
|
||||
description: `Processed ${data.accountsProcessed} accounts and found ${data.foldersFound} folders`,
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
// Refresh the page to see changes
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to Update Folders",
|
||||
description: data.error || 'Unknown error',
|
||||
variant: "destructive",
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fix folders error:', error);
|
||||
toast({
|
||||
title: "Folder Update Error",
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: "destructive",
|
||||
duration: 5000
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-white text-gray-700 hover:text-gray-900 shadow-md"
|
||||
onClick={handleDebug}
|
||||
disabled={loading}
|
||||
>
|
||||
<Bug className="h-3.5 w-3.5 mr-1" />
|
||||
Debug
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-white text-gray-700 hover:text-gray-900 shadow-md"
|
||||
onClick={handleFixFolders}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Fix Folders
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
app/components/debug/RedisCacheStatus.tsx
Normal file
52
app/components/debug/RedisCacheStatus.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
|
||||
export function RedisCacheStatus() {
|
||||
const [status, setStatus] = useState<'loading' | 'connected' | 'error'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
// Don't run in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkRedisStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/redis/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ready) {
|
||||
setStatus('connected');
|
||||
// No need to dynamically import EmailDebug - it's managed by the debug-tool component
|
||||
} else {
|
||||
setStatus('error');
|
||||
toast({
|
||||
title: "Redis Connection Issue",
|
||||
description: "Redis cache is not responding. Email data may be slow to load.",
|
||||
variant: "destructive",
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
checkRedisStatus();
|
||||
}, []);
|
||||
|
||||
// In development, render a minimal indicator
|
||||
if (process.env.NODE_ENV !== 'production' && status !== 'loading') {
|
||||
return (
|
||||
<div className="fixed top-0 right-0 m-2 z-50">
|
||||
<div className={`h-2 w-2 rounded-full ${
|
||||
status === 'connected' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
headline: string;
|
||||
projectName: string;
|
||||
projectId: number;
|
||||
status: number;
|
||||
dueDate: string | null;
|
||||
milestone: string | null;
|
||||
details: string | null;
|
||||
createdOn: string;
|
||||
editedOn: string | null;
|
||||
assignedTo: number[];
|
||||
}
|
||||
|
||||
export default function Flow() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getStatusLabel = (status: number): string => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'New';
|
||||
case 2:
|
||||
return 'In Progress';
|
||||
case 3:
|
||||
return 'Done';
|
||||
case 4:
|
||||
return 'In Progress';
|
||||
case 5:
|
||||
return 'Done';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: number): string => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
|
||||
case 2:
|
||||
case 4:
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||
case 3:
|
||||
case 5:
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/leantime/tasks');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tasks');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.tasks && Array.isArray(data.tasks)) {
|
||||
// Sort tasks by creation date (oldest first)
|
||||
const sortedTasks = data.tasks.sort((a: Task, b: Task) => {
|
||||
const dateA = new Date(a.createdOn).getTime();
|
||||
const dateB = new Date(b.createdOn).getTime();
|
||||
return dateA - dateB;
|
||||
});
|
||||
setTasks(sortedTasks);
|
||||
} else {
|
||||
console.error('Invalid tasks data format:', data);
|
||||
setError('Invalid tasks data format');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching tasks:', err);
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return <div>No tasks found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{task.headline}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{task.projectName}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
|
||||
task.status
|
||||
)}`}
|
||||
>
|
||||
{getStatusLabel(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,73 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
interface ResponsiveIframeProps {
|
||||
export interface ResponsiveIframeProps {
|
||||
src: string;
|
||||
title?: string;
|
||||
token?: string;
|
||||
className?: string;
|
||||
allow?: string;
|
||||
style?: React.CSSProperties;
|
||||
hideUntilLoad?: boolean;
|
||||
allowFullScreen?: boolean;
|
||||
scrolling?: boolean;
|
||||
heightOffset?: number;
|
||||
}
|
||||
|
||||
export function ResponsiveIframe({ src, className = '', allow, style }: ResponsiveIframeProps) {
|
||||
// Map of service prefixes to their base URLs - keep in sync with proxy route.ts
|
||||
const SERVICE_URLS: Record<string, string> = {
|
||||
'parole': process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '',
|
||||
'alma': process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || '',
|
||||
'crm': process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || '',
|
||||
'vision': process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || '',
|
||||
'showcase': process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || '',
|
||||
'agilite': process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || '',
|
||||
'dossiers': process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || '',
|
||||
'the-message': process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || '',
|
||||
'qg': process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || '',
|
||||
'design': process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '',
|
||||
};
|
||||
|
||||
export default function ResponsiveIframe({
|
||||
src,
|
||||
title = 'Embedded content',
|
||||
token,
|
||||
className = '',
|
||||
style = {},
|
||||
hideUntilLoad = false,
|
||||
allowFullScreen = false,
|
||||
scrolling = true,
|
||||
heightOffset = 0,
|
||||
}: ResponsiveIframeProps) {
|
||||
const [height, setHeight] = useState<number>(0);
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const [authError, setAuthError] = useState<boolean>(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const silentAuthRef = useRef<HTMLIFrameElement>(null);
|
||||
const silentAuthTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { data: session } = useSession();
|
||||
|
||||
// Convert proxy URLs to direct URLs to avoid double proxying
|
||||
const processedSrc = (() => {
|
||||
if (src.startsWith('/api/proxy/')) {
|
||||
// Extract the service name and path
|
||||
const parts = src.replace('/api/proxy/', '').split('/');
|
||||
const serviceName = parts[0];
|
||||
const path = parts.slice(1).join('/');
|
||||
|
||||
// Look up the base URL for this service
|
||||
const baseUrl = SERVICE_URLS[serviceName];
|
||||
if (baseUrl) {
|
||||
console.log(`Converting proxy URL to direct URL: ${src} -> ${baseUrl}/${path}`);
|
||||
return `${baseUrl}/${path}`;
|
||||
}
|
||||
}
|
||||
return src;
|
||||
})();
|
||||
|
||||
// Append token to src if provided
|
||||
const fullSrc = token ? `${processedSrc}${processedSrc.includes('?') ? '&' : '?'}token=${token}` : processedSrc;
|
||||
|
||||
// Handle silent authentication refresh
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
// Setup silent authentication check every 15 minutes
|
||||
const setupSilentAuth = () => {
|
||||
if (silentAuthTimerRef.current) {
|
||||
clearTimeout(silentAuthTimerRef.current);
|
||||
}
|
||||
|
||||
// Create a hidden iframe to check authentication status
|
||||
silentAuthTimerRef.current = setTimeout(() => {
|
||||
console.log('Running silent authentication check');
|
||||
|
||||
// Create the silent auth iframe if it doesn't exist
|
||||
if (silentAuthRef.current && !silentAuthRef.current.src) {
|
||||
silentAuthRef.current.src = '/silent-refresh';
|
||||
}
|
||||
|
||||
// Setup next check
|
||||
setupSilentAuth();
|
||||
}, 15 * 60 * 1000); // 15 minutes
|
||||
};
|
||||
|
||||
// Handle messages from the silent auth iframe
|
||||
const handleAuthMessage = (event: MessageEvent) => {
|
||||
if (event.data && event.data.type === 'AUTH_STATUS') {
|
||||
console.log('Received auth status:', event.data);
|
||||
|
||||
if (event.data.status === 'UNAUTHENTICATED') {
|
||||
console.error('Silent authentication failed - user is not authenticated');
|
||||
setAuthError(true);
|
||||
|
||||
// Force immediate refresh
|
||||
if (silentAuthRef.current) {
|
||||
silentAuthRef.current.src = '/silent-refresh';
|
||||
}
|
||||
} else if (event.data.status === 'AUTHENTICATED') {
|
||||
console.log('Silent authentication successful');
|
||||
setAuthError(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleAuthMessage);
|
||||
setupSilentAuth();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('message', handleAuthMessage);
|
||||
if (silentAuthTimerRef.current) {
|
||||
clearTimeout(silentAuthTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const calculateHeight = () => {
|
||||
const pageY = (elem: HTMLElement): number => {
|
||||
return elem.offsetParent ?
|
||||
(elem.offsetTop + pageY(elem.offsetParent as HTMLElement)) :
|
||||
elem.offsetTop;
|
||||
};
|
||||
|
||||
const height = document.documentElement.clientHeight;
|
||||
const iframeY = pageY(iframe);
|
||||
const newHeight = Math.max(0, height - iframeY);
|
||||
iframe.style.height = `${newHeight}px`;
|
||||
// Adjust iframe height based on window size
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const newHeight = viewportHeight - heightOffset;
|
||||
setHeight(newHeight > 0 ? newHeight : 0);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
window.addEventListener('resize', updateHeight);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateHeight);
|
||||
};
|
||||
}, [heightOffset]);
|
||||
|
||||
// Handle hash changes by updating iframe source
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
if (window.location.hash && window.location.hash.length) {
|
||||
const iframeURL = new URL(iframe.src);
|
||||
iframeURL.hash = window.location.hash;
|
||||
iframe.src = iframeURL.toString();
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe && iframe.src) {
|
||||
const url = new URL(iframe.src);
|
||||
|
||||
// If there's a hash in the parent window's URL
|
||||
if (window.location.hash) {
|
||||
url.hash = window.location.hash;
|
||||
iframe.src = url.toString();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial setup
|
||||
calculateHeight();
|
||||
handleHashChange();
|
||||
|
||||
// Event listeners
|
||||
window.addEventListener('resize', calculateHeight);
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
iframe.addEventListener('load', calculateHeight);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('resize', calculateHeight);
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
iframe.removeEventListener('load', calculateHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
id="myFrame"
|
||||
src={src}
|
||||
className={`w-full border-none ${className}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
...style
|
||||
}}
|
||||
allow={allow}
|
||||
allowFullScreen
|
||||
/>
|
||||
<>
|
||||
{/* Hidden iframe for silent authentication */}
|
||||
<iframe
|
||||
ref={silentAuthRef}
|
||||
style={{ display: 'none' }}
|
||||
title="Silent Authentication"
|
||||
/>
|
||||
|
||||
{/* Main content iframe */}
|
||||
<div className="relative w-full h-full">
|
||||
{authError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-red-50 bg-opacity-90 z-10">
|
||||
<div className="text-center p-4">
|
||||
<p className="text-red-600 font-semibold">Session expired or authentication error</p>
|
||||
<button
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
onClick={() => window.location.href = '/api/auth/signin?callbackUrl=' + encodeURIComponent(window.location.href)}
|
||||
>
|
||||
Sign in again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={fullSrc}
|
||||
title={title}
|
||||
className={`w-full ${className}`}
|
||||
style={{
|
||||
height: height > 0 ? `${height}px` : '100%',
|
||||
border: 'none',
|
||||
visibility: hideUntilLoad && !loaded ? 'hidden' : 'visible',
|
||||
...style,
|
||||
}}
|
||||
onLoad={() => {
|
||||
setLoaded(true);
|
||||
}}
|
||||
allowFullScreen={allowFullScreen}
|
||||
scrolling={scrolling ? 'yes' : 'no'}
|
||||
/>
|
||||
|
||||
{hideUntilLoad && !loaded && (
|
||||
<div className="flex justify-center items-center w-full h-full absolute top-0 left-0">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
app/components/rocket-auth.tsx
Normal file
82
app/components/rocket-auth.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
export function RocketChatAuth() {
|
||||
const { data: session } = useSession();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function authenticateWithRocketChat() {
|
||||
if (!session?.user?.email) {
|
||||
setError('No user session available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Authenticating with Rocket.Chat for user:', session.user.email);
|
||||
|
||||
// Call our API to get Rocket.Chat tokens
|
||||
const response = await fetch('/api/auth/rocket-login');
|
||||
|
||||
// Get the text response for debugging if there's an error
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to authenticate with Rocket.Chat:', responseText);
|
||||
setError(`Failed to authenticate: ${response.status} ${response.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the JSON now that we've read the text
|
||||
const data = JSON.parse(responseText);
|
||||
|
||||
if (data.rocketChatToken && data.rocketChatUserId) {
|
||||
console.log('Received tokens from API:', {
|
||||
hasToken: !!data.rocketChatToken,
|
||||
hasUserId: !!data.rocketChatUserId
|
||||
});
|
||||
|
||||
// Get the current hostname to set domain cookies properly
|
||||
const hostname = window.location.hostname;
|
||||
const domain = hostname.includes('localhost') ? 'localhost' : hostname;
|
||||
|
||||
console.log(`Setting cookies for domain: ${domain}`);
|
||||
|
||||
// Store tokens in cookies that can be accessed by the Rocket.Chat iframe
|
||||
document.cookie = `rc_token=${data.rocketChatToken}; path=/; domain=${domain}; SameSite=None; Secure`;
|
||||
document.cookie = `rc_uid=${data.rocketChatUserId}; path=/; domain=${domain}; SameSite=None; Secure`;
|
||||
|
||||
// Also store in localStorage which Rocket.Chat might check
|
||||
localStorage.setItem('Meteor.loginToken', data.rocketChatToken);
|
||||
localStorage.setItem('Meteor.userId', data.rocketChatUserId);
|
||||
|
||||
console.log('Successfully authenticated with Rocket.Chat');
|
||||
setIsAuthenticated(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('Received invalid response from authentication API');
|
||||
console.error('Invalid response data:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Error authenticating with Rocket.Chat:', errorMessage);
|
||||
setError(`Error: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
authenticateWithRocketChat();
|
||||
}, [session]);
|
||||
|
||||
// This component doesn't render visible UI by default
|
||||
return error ? (
|
||||
<div className="fixed bottom-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50" role="alert">
|
||||
<strong className="font-bold">Authentication Error: </strong>
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default RocketChatAuth;
|
||||
67
app/courrier/components/DebugView.tsx
Normal file
67
app/courrier/components/DebugView.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Account {
|
||||
id: number | string;
|
||||
name: string;
|
||||
email: string;
|
||||
color: string;
|
||||
folders?: string[];
|
||||
}
|
||||
|
||||
interface DebugViewProps {
|
||||
accounts: Account[];
|
||||
selectedAccount: Account | null;
|
||||
mailboxes: string[];
|
||||
}
|
||||
|
||||
export default function DebugView({ accounts, selectedAccount, mailboxes }: DebugViewProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<Button
|
||||
className="fixed bottom-4 right-4 bg-red-600 hover:bg-red-700 text-white z-50"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Debug
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-50 overflow-auto flex flex-col p-4">
|
||||
<div className="bg-white rounded-lg p-4 mb-4 max-w-3xl mx-auto w-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Debug View</h2>
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)}>Close</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Accounts ({accounts.length})</h3>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(accounts, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Selected Account</h3>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-[300px]">
|
||||
{selectedAccount ? JSON.stringify(selectedAccount, null, 2) : 'None'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Mailboxes ({mailboxes?.length || 0})</h3>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-[150px]">
|
||||
{JSON.stringify(mailboxes, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
app/courrier/layout.tsx
Normal file
11
app/courrier/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
export default function CourrierLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,113 +0,0 @@
|
||||
/**
|
||||
* This is a debugging component that provides troubleshooting tools
|
||||
* for the email loading process in the Courrier application.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface LoadingFixProps {
|
||||
loading: boolean;
|
||||
isLoadingInitial: boolean;
|
||||
setLoading: (value: boolean) => void;
|
||||
setIsLoadingInitial: (value: boolean) => void;
|
||||
setEmails: (emails: any[]) => void;
|
||||
loadEmails: () => void;
|
||||
emails: any[];
|
||||
}
|
||||
|
||||
export function LoadingFix({
|
||||
loading,
|
||||
isLoadingInitial,
|
||||
setLoading,
|
||||
setIsLoadingInitial,
|
||||
setEmails,
|
||||
loadEmails,
|
||||
emails
|
||||
}: LoadingFixProps) {
|
||||
const forceResetLoadingStates = () => {
|
||||
console.log('[DEBUG] Force resetting loading states to false');
|
||||
// Force both loading states to false
|
||||
setLoading(false);
|
||||
setIsLoadingInitial(false);
|
||||
};
|
||||
|
||||
const forceTriggerLoad = () => {
|
||||
console.log('[DEBUG] Force triggering a new email load');
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
loadEmails();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const resetEmailState = () => {
|
||||
console.log('[DEBUG] Resetting email state to empty array');
|
||||
setEmails([]);
|
||||
setTimeout(() => {
|
||||
forceTriggerLoad();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 p-4 bg-white shadow-lg rounded-lg border border-gray-200">
|
||||
<div className="text-sm font-medium mb-2">Debug Tools</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center text-xs">
|
||||
<span className="inline-block w-28">Loading State:</span>
|
||||
{loading ? (
|
||||
<span className="text-yellow-500 flex items-center">
|
||||
<AlertCircle className="h-3 w-3 mr-1" /> Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-green-500 flex items-center">
|
||||
<CheckCircle className="h-3 w-3 mr-1" /> Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center text-xs">
|
||||
<span className="inline-block w-28">Initial Loading:</span>
|
||||
{isLoadingInitial ? (
|
||||
<span className="text-yellow-500 flex items-center">
|
||||
<AlertCircle className="h-3 w-3 mr-1" /> Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-green-500 flex items-center">
|
||||
<CheckCircle className="h-3 w-3 mr-1" /> Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center text-xs">
|
||||
<span className="inline-block w-28">Emails Loaded:</span>
|
||||
<span className={emails.length > 0 ? "text-green-500" : "text-red-500"}>
|
||||
{emails.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-8"
|
||||
onClick={forceResetLoadingStates}
|
||||
>
|
||||
Reset Loading States
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-8"
|
||||
onClick={forceTriggerLoad}
|
||||
>
|
||||
Force Reload Emails
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-8"
|
||||
onClick={resetEmailState}
|
||||
>
|
||||
Reset Email State
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function MailLoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [host, setHost] = useState('mail.infomaniak.com');
|
||||
const [port, setPort] = useState('993');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/courrier/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
host,
|
||||
port,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to connect to email server');
|
||||
}
|
||||
|
||||
// Redirect to mail page
|
||||
router.push('/mail');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="host">IMAP Host</Label>
|
||||
<Input
|
||||
id="host"
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="port">IMAP Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="text"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm">{error}</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,12 +15,12 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
className="relative"
|
||||
style={{
|
||||
marginTop: '-50px', // Adjust this value based on the Nextcloud navbar height
|
||||
height: 'calc(100% + 50px)' // Compensate for the negative margin
|
||||
}}
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_GITE_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
250
app/globals.css
250
app/globals.css
@ -74,17 +74,241 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Email specific styles */
|
||||
.email-content table { width: 100%; border-collapse: collapse; }
|
||||
.email-content table.table-container { width: auto; margin-bottom: 20px; }
|
||||
.email-content td, .email-content th { padding: 8px; border: 1px solid #e5e7eb; }
|
||||
.email-content img { max-width: 100%; height: auto; }
|
||||
.email-content div[style] { max-width: 100% !important; }
|
||||
.email-content * { max-width: 100% !important; word-wrap: break-word; }
|
||||
.email-content font { font-family: inherit; }
|
||||
.email-content .total-row td { border-top: 1px solid #e5e7eb; }
|
||||
.email-content a { color: #3b82f6; text-decoration: underline; }
|
||||
.email-content p { margin-bottom: 0.75em; }
|
||||
.email-content .header { margin-bottom: 1em; }
|
||||
.email-content .footer { font-size: 0.875rem; color: #6b7280; margin-top: 1em; }
|
||||
/* Email display styles */
|
||||
.email-content-display {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Preserve email structure */
|
||||
.email-content-display * {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.email-content-display img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.email-content-display table {
|
||||
width: 100% !important;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
table-layout: fixed;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* Table wrapper for overflow handling */
|
||||
.email-content-display div:has(> table) {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.email-content-display td,
|
||||
.email-content-display th {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Make sure quoted content tables are properly displayed */
|
||||
.email-content-display .quoted-content table,
|
||||
.email-content-display blockquote table {
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
width: 100% !important;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.email-content-display .quoted-content td,
|
||||
.email-content-display .quoted-content th,
|
||||
.email-content-display blockquote td,
|
||||
.email-content-display blockquote th {
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* Quote blocks for email replies */
|
||||
.email-content-display blockquote,
|
||||
.email-content-display .quoted-content {
|
||||
margin: 16px 0;
|
||||
padding: 8px 16px;
|
||||
border-left: 2px solid #ddd;
|
||||
color: #505050;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Special classes used in the email formatting functions */
|
||||
.email-content-display .reply-body {
|
||||
width: 100%;
|
||||
font-family: Arial, sans-serif;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.email-content-display .quote-header {
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
margin: 20px 0 10px 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.email-content-display .quoted-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.email-content-display .email-original-content {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* Fix styles for the content in both preview and compose */
|
||||
.email-content-display[contenteditable="false"] {
|
||||
/* Same styles as contentEditable=true to ensure consistency */
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Quill editor customizations for email composition */
|
||||
.ql-editor {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
padding: 12px;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Quote formatting for forwarded/replied emails */
|
||||
.ql-editor blockquote {
|
||||
border-left: 2px solid #ddd !important;
|
||||
padding: 10px 0 10px 15px !important;
|
||||
margin: 8px 0 !important;
|
||||
color: #505050 !important;
|
||||
background-color: #f9f9f9 !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Table formatting in the editor */
|
||||
.ql-editor table {
|
||||
width: 100% !important;
|
||||
border-collapse: collapse !important;
|
||||
table-layout: fixed !important;
|
||||
margin: 10px 0 !important;
|
||||
border: 1px solid #ddd !important;
|
||||
}
|
||||
|
||||
.ql-editor td,
|
||||
.ql-editor th {
|
||||
border: 1px solid #ddd !important;
|
||||
padding: 6px 8px !important;
|
||||
overflow-wrap: break-word !important;
|
||||
word-break: break-word !important;
|
||||
min-width: 30px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Fix toolbar button styling */
|
||||
.ql-toolbar.ql-snow {
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.ql-container.ql-snow {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Style for "On [date], [person] wrote:" line */
|
||||
.ql-editor div[style*="font-weight: 400"] {
|
||||
margin-top: 20px !important;
|
||||
margin-bottom: 8px !important;
|
||||
color: #555 !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Support for RTL content */
|
||||
.email-content-display[dir="rtl"],
|
||||
.email-content-display [dir="rtl"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Remove any padding/margins from the first and last elements */
|
||||
.email-content-display > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.email-content-display > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Forwarded message header styling */
|
||||
.email-content-display div {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Forwarded message styling */
|
||||
.email-content-display div[style*="forwarded message"],
|
||||
.email-content-display div[class*="forwarded-message"],
|
||||
.email-content-display div[class*="forwarded_message"] {
|
||||
color: #555;
|
||||
font-family: Arial, sans-serif;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.email-content-display button,
|
||||
.email-content-display a[role="button"],
|
||||
.email-content-display a.button,
|
||||
.email-content-display div.button,
|
||||
.email-content-display [class*="btn"],
|
||||
.email-content-display [class*="button"] {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
background-color: #f97316;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin: 8px 0;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.email-content-display a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Headers and text */
|
||||
.email-content-display h1,
|
||||
.email-content-display h2,
|
||||
.email-content-display h3 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.email-content-display p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
136
app/hooks/use-courrier.ts
Normal file
136
app/hooks/use-courrier.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Change the current folder and load emails from that folder
|
||||
*/
|
||||
const changeFolder = async (folder: string, accountId?: string) => {
|
||||
console.log(`Changing folder to ${folder} for account ${accountId || 'default'}`);
|
||||
try {
|
||||
// Reset selected email
|
||||
setSelectedEmail(null);
|
||||
setSelectedEmailIds([]);
|
||||
|
||||
// Record the new folder
|
||||
setCurrentFolder(folder);
|
||||
|
||||
// Reset search query when changing folders
|
||||
setSearchQuery('');
|
||||
|
||||
// Reset to page 1
|
||||
setPage(1);
|
||||
|
||||
// Clear existing emails before loading new ones to prevent UI flicker
|
||||
setEmails([]);
|
||||
|
||||
// Show loading state
|
||||
setIsLoading(true);
|
||||
|
||||
// Load emails for the new folder with a deliberate delay to allow state to update
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await loadEmails(folder, 1, 20, accountId);
|
||||
} catch (error) {
|
||||
console.error(`Error changing to folder ${folder}:`, error);
|
||||
setError(`Failed to load emails from ${folder}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load emails for the current folder
|
||||
*/
|
||||
const loadEmails = async (
|
||||
folderOverride?: string,
|
||||
pageOverride?: number,
|
||||
perPageOverride?: number,
|
||||
accountIdOverride?: string
|
||||
) => {
|
||||
const folderToUse = folderOverride || currentFolder;
|
||||
|
||||
// Enhanced folder and account handling
|
||||
let normalizedFolder = folderToUse;
|
||||
let normalizedAccountId = accountIdOverride;
|
||||
|
||||
// Extract account ID from folder if it has a prefix and no explicit account ID was provided
|
||||
if (folderToUse.includes(':')) {
|
||||
const [folderAccountId, baseFolderName] = folderToUse.split(':');
|
||||
console.log(`Folder has prefix: accountId=${folderAccountId}, baseName=${baseFolderName}`);
|
||||
|
||||
// If no explicit account ID was provided, use the one from the folder name
|
||||
if (!normalizedAccountId) {
|
||||
normalizedAccountId = folderAccountId;
|
||||
console.log(`Using account ID from folder prefix: ${normalizedAccountId}`);
|
||||
}
|
||||
// If both exist but don't match, log a warning
|
||||
else if (normalizedAccountId !== folderAccountId) {
|
||||
console.warn(`⚠️ Mismatch between folder account prefix (${folderAccountId}) and provided accountId (${normalizedAccountId})`);
|
||||
console.warn(`Using provided accountId (${normalizedAccountId}) but this may cause unexpected behavior`);
|
||||
}
|
||||
}
|
||||
|
||||
const pageToUse = pageOverride || page;
|
||||
const perPageToUse = perPageOverride || perPage;
|
||||
|
||||
console.log(`Loading emails: folder=${folderToUse}, page=${pageToUse}, accountId=${normalizedAccountId || 'default'}`);
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
// Construct the API URL with a unique timestamp to prevent caching
|
||||
let url = `/api/courrier/emails?folder=${encodeURIComponent(folderToUse)}&page=${pageToUse}&perPage=${perPageToUse}`;
|
||||
|
||||
// Add accountId parameter if specified
|
||||
if (normalizedAccountId) {
|
||||
url += `&accountId=${encodeURIComponent(normalizedAccountId)}`;
|
||||
}
|
||||
|
||||
// Add cache-busting timestamp
|
||||
url += `&_t=${Date.now()}`;
|
||||
|
||||
console.log(`Fetching emails from API: ${url}`);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorText;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorText = errorData.error || `Server error: ${response.status}`;
|
||||
} catch {
|
||||
errorText = `HTTP error: ${response.status}`;
|
||||
}
|
||||
console.error(`API error: ${errorText}`);
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`Received ${data.emails?.length || 0} emails from API`);
|
||||
|
||||
if (pageToUse === 1 || !pageOverride) {
|
||||
// Replace emails when loading first page
|
||||
console.log(`Setting ${data.emails?.length || 0} emails (replacing existing)`);
|
||||
setEmails(data.emails || []);
|
||||
} else {
|
||||
// Append emails when loading subsequent pages
|
||||
console.log(`Appending ${data.emails?.length || 0} emails to existing list`);
|
||||
setEmails(prev => [...prev, ...(data.emails || [])]);
|
||||
}
|
||||
|
||||
// Update pagination info
|
||||
setTotalPages(data.totalPages || 0);
|
||||
if (data.mailboxes && data.mailboxes.length > 0) {
|
||||
console.log(`Received ${data.mailboxes.length} mailboxes from API`);
|
||||
setMailboxes(data.mailboxes);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error loading emails:', error);
|
||||
setError(`Failed to load emails: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
// Set empty emails array on error to prevent UI issues
|
||||
if (pageToUse === 1) {
|
||||
setEmails([]);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@ -6,26 +6,44 @@ import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { LayoutWrapper } from "@/components/layout/layout-wrapper";
|
||||
import { warmupRedisCache } from '@/lib/redis';
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
// Warm up Redis connection during app initialization
|
||||
warmupRedisCache().catch(console.error);
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
// Try to get the session, but handle potential errors gracefully
|
||||
let session = null;
|
||||
let sessionError = false;
|
||||
|
||||
try {
|
||||
session = await getServerSession(authOptions);
|
||||
} catch (error) {
|
||||
console.error("Error getting server session:", error);
|
||||
sessionError = true;
|
||||
}
|
||||
|
||||
const headersList = await headers();
|
||||
const pathname = headersList.get("x-pathname") || "";
|
||||
const isSignInPage = pathname === "/signin";
|
||||
|
||||
// If we're on the signin page and there was a session error,
|
||||
// don't pass the session to avoid refresh attempts
|
||||
const safeSession = isSignInPage && sessionError ? null : session;
|
||||
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body className={inter.className}>
|
||||
<Providers>
|
||||
<Providers session={safeSession}>
|
||||
<LayoutWrapper
|
||||
isSignInPage={isSignInPage}
|
||||
isAuthenticated={!!session}
|
||||
isAuthenticated={!!session && !sessionError}
|
||||
>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
|
||||
7
app/loggedout/layout.tsx
Normal file
7
app/loggedout/layout.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function LoggedOutLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
282
app/loggedout/page.tsx
Normal file
282
app/loggedout/page.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function LoggedOutPage() {
|
||||
const [sessionStatus, setSessionStatus] = useState<'checking' | 'cleared' | 'error'>('checking');
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [keycloakLogoutUrl, setKeycloakLogoutUrl] = useState<string | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const preserveSso = searchParams.get('preserveSso') === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for messages from iframes
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data && event.data.type === 'AUTH_STATUS') {
|
||||
console.log('Received auth status message:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
const clearSessions = async () => {
|
||||
try {
|
||||
console.log(`Clearing sessions (preserveSso: ${preserveSso})`);
|
||||
|
||||
// Try to get any stored user IDs for server-side cleanup
|
||||
let userId = null;
|
||||
try {
|
||||
// Check localStorage first
|
||||
userId = localStorage.getItem('userId') || sessionStorage.getItem('userId');
|
||||
|
||||
// Try to get from sessionStorage as fallback
|
||||
if (!userId) {
|
||||
const sessionData = sessionStorage.getItem('nextauth.session-token');
|
||||
if (sessionData) {
|
||||
try {
|
||||
const parsed = JSON.parse(atob(sessionData.split('.')[1]));
|
||||
userId = parsed.sub || parsed.id;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse session data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error accessing storage:', e);
|
||||
}
|
||||
|
||||
// If we found a user ID, call server-side cleanup
|
||||
if (userId) {
|
||||
console.log(`Found user ID: ${userId}, cleaning up server-side`);
|
||||
try {
|
||||
const response = await fetch('/api/auth/session-cleanup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
preserveSso
|
||||
}),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Server cleanup result:', result);
|
||||
} catch (e) {
|
||||
console.error('Error during server-side cleanup:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// If not preserving SSO, get Keycloak logout URL
|
||||
if (!preserveSso) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/full-logout', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success && data.logoutUrl) {
|
||||
setKeycloakLogoutUrl(data.logoutUrl);
|
||||
console.log('Keycloak logout URL:', data.logoutUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get Keycloak logout URL:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cookies appropriately based on preserve SSO setting
|
||||
const cookies = document.cookie.split(';');
|
||||
|
||||
// Get all cookies names
|
||||
const cookieNames = cookies.map(cookie => cookie.split('=')[0].trim());
|
||||
|
||||
// Find chunked cookies
|
||||
const chunkedCookies = cookieNames
|
||||
.filter(name => /next-auth.*\.\d+$/.test(name));
|
||||
|
||||
// Define cookies to clear
|
||||
let cookiesToClear = [];
|
||||
|
||||
if (preserveSso) {
|
||||
// Only clear app-specific cookies if preserving SSO
|
||||
cookiesToClear = [
|
||||
'next-auth.session-token',
|
||||
'next-auth.csrf-token',
|
||||
'next-auth.callback-url',
|
||||
'__Secure-next-auth.session-token',
|
||||
'__Host-next-auth.csrf-token',
|
||||
...chunkedCookies
|
||||
];
|
||||
} else {
|
||||
// Clear ALL auth-related cookies for full logout
|
||||
const authCookies = cookieNames.filter(name =>
|
||||
name.includes('auth') ||
|
||||
name.includes('KEYCLOAK') ||
|
||||
name.includes('KC_') ||
|
||||
name.includes('session')
|
||||
);
|
||||
|
||||
cookiesToClear = [
|
||||
...authCookies,
|
||||
...chunkedCookies,
|
||||
'JSESSIONID',
|
||||
'KEYCLOAK_SESSION',
|
||||
'KEYCLOAK_IDENTITY',
|
||||
'KC_RESTART'
|
||||
];
|
||||
}
|
||||
|
||||
// Clear the cookies with all possible path and domain combinations
|
||||
const hostname = window.location.hostname;
|
||||
const baseDomain = hostname.split('.').slice(-2).join('.');
|
||||
|
||||
cookiesToClear.forEach(cookieName => {
|
||||
// Try various path and domain combinations to ensure complete cleanup
|
||||
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${hostname};`;
|
||||
|
||||
// Only try this for multi-part domains
|
||||
if (hostname !== baseDomain) {
|
||||
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${baseDomain};`;
|
||||
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${hostname};`;
|
||||
}
|
||||
|
||||
// Add secure and SameSite attributes
|
||||
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure;`;
|
||||
});
|
||||
|
||||
// Clear session storage
|
||||
try {
|
||||
sessionStorage.clear();
|
||||
} catch (e) {
|
||||
console.error('Failed to clear sessionStorage:', e);
|
||||
}
|
||||
|
||||
// Clear auth-related localStorage items
|
||||
try {
|
||||
localStorage.removeItem('userId');
|
||||
localStorage.removeItem('userName');
|
||||
localStorage.removeItem('userEmail');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refreshToken');
|
||||
|
||||
// Rocket Chat items
|
||||
localStorage.removeItem('Meteor.loginToken');
|
||||
localStorage.removeItem('Meteor.userId');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear localStorage items:', e);
|
||||
}
|
||||
|
||||
// Notify parent window if we're in an iframe
|
||||
if (window !== window.parent) {
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'AUTH_STATUS',
|
||||
status: 'LOGGED_OUT',
|
||||
preserveSso
|
||||
}, '*');
|
||||
} catch (e) {
|
||||
console.error('Failed to send logout message to parent:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setSessionStatus('cleared');
|
||||
setMessage(preserveSso
|
||||
? 'You have been logged out of this application, but your SSO session is still active for other services.'
|
||||
: 'You have been completely logged out of all services.'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error during logout cleanup:', error);
|
||||
setSessionStatus('error');
|
||||
setMessage('There was an error during the logout process.');
|
||||
}
|
||||
};
|
||||
|
||||
clearSessions();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [preserveSso]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 p-4">
|
||||
<div className="w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{preserveSso ? 'Application Sign Out' : 'Complete Sign Out'}
|
||||
</h1>
|
||||
|
||||
{sessionStatus === 'checking' && (
|
||||
<p className="text-gray-600">Cleaning up your session...</p>
|
||||
)}
|
||||
|
||||
{sessionStatus === 'cleared' && (
|
||||
<>
|
||||
<p className="text-gray-600 mb-4">{message}</p>
|
||||
|
||||
{preserveSso ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
You can continue using other applications without signing in again.
|
||||
</p>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Return to Home Page
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.location.href = '/loggedout'}
|
||||
className="inline-block px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Sign Out Completely
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
You will need to sign in again to access any protected services.
|
||||
</p>
|
||||
|
||||
{keycloakLogoutUrl && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={keycloakLogoutUrl}
|
||||
style={{ width: '1px', height: '1px', position: 'absolute', top: '-100px', left: '-100px' }}
|
||||
title="Keycloak Logout"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/api/auth/signin?fresh=true"
|
||||
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In Again
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{sessionStatus === 'error' && (
|
||||
<>
|
||||
<p className="text-red-600 mb-4">{message}</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Return to Home Page
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<iframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL}
|
||||
className="w-full h-full border-none"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
style={{
|
||||
marginTop: '-64px'
|
||||
}}
|
||||
|
||||
103
app/ms/page.tsx
Normal file
103
app/ms/page.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function MicrosoftCallbackPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState<string>('Processing authentication...');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorDetails, setErrorDetails] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function handleCallback() {
|
||||
try {
|
||||
// Extract code and state from URL
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
const errorMsg = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
|
||||
if (errorMsg) {
|
||||
setError(`Authentication error: ${errorMsg}`);
|
||||
if (errorDescription) {
|
||||
// URL decode the error description
|
||||
const decodedErrorDescription = decodeURIComponent(errorDescription);
|
||||
setErrorDetails(decodedErrorDescription);
|
||||
console.error('Auth error details:', decodedErrorDescription);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setError('Missing required parameters');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Authentication successful. Exchanging code for tokens...');
|
||||
|
||||
// Send code to our API to exchange for tokens
|
||||
const response = await fetch('/api/courrier/microsoft/callback', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code, state }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Failed to process authentication');
|
||||
if (data.details) {
|
||||
setErrorDetails(data.details);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Account connected successfully!');
|
||||
|
||||
// Redirect back to email client after a short delay
|
||||
setTimeout(() => {
|
||||
router.push('/courrier');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred');
|
||||
console.error('Callback processing error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback();
|
||||
}, [router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-md p-8 space-y-4 bg-white rounded-xl shadow-md">
|
||||
<h1 className="text-2xl font-bold text-center">Microsoft Account Connection</h1>
|
||||
|
||||
{error ? (
|
||||
<div className="p-4 bg-red-50 text-red-700 rounded-md">
|
||||
<p className="font-medium">Error</p>
|
||||
<p>{error}</p>
|
||||
{errorDetails && (
|
||||
<div className="mt-2 p-2 bg-red-100 rounded text-sm overflow-auto max-h-40">
|
||||
<p>{errorDetails}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push('/courrier')}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Return to Email Client
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-blue-50 text-blue-700 rounded-md">
|
||||
<p>{status}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,7 @@ export default function NotificationsPage() {
|
||||
<iframe
|
||||
src="https://example.com/notifications"
|
||||
className="w-full h-full border-none"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -36,7 +36,7 @@ export default function Home() {
|
||||
<QuoteCard />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Calendar />
|
||||
<Calendar limit={5} showMore={true} showRefresh={true} />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<News />
|
||||
|
||||
@ -1,7 +1,67 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
import RocketChatAuth from "@/app/components/rocket-auth";
|
||||
|
||||
// Function to get Rocket.Chat token for server-side authentication
|
||||
async function getRocketChatTokensServer(email: string) {
|
||||
try {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
|
||||
if (!baseUrl) return null;
|
||||
|
||||
// Admin headers for Rocket.Chat API
|
||||
const adminHeaders = {
|
||||
'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!,
|
||||
'X-User-Id': process.env.ROCKET_CHAT_USER_ID!,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Get the username from email
|
||||
const username = email.split('@')[0];
|
||||
|
||||
// Get all users to find the current user
|
||||
const usersResponse = await fetch(`${baseUrl}/api/v1/users.list`, {
|
||||
method: 'GET',
|
||||
headers: adminHeaders,
|
||||
cache: 'no-store' // Don't cache this request
|
||||
});
|
||||
|
||||
if (!usersResponse.ok) return null;
|
||||
|
||||
const usersData = await usersResponse.json();
|
||||
|
||||
// Find the current user in the list
|
||||
const currentUser = usersData.users.find((user: any) =>
|
||||
user.username === username ||
|
||||
(user.emails && user.emails.some((emailObj: any) => emailObj.address === email))
|
||||
);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
// Create a token for the current user
|
||||
const createTokenResponse = await fetch(`${baseUrl}/api/v1/users.createToken`, {
|
||||
method: 'POST',
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify({
|
||||
userId: currentUser._id
|
||||
}),
|
||||
cache: 'no-store' // Don't cache this request
|
||||
});
|
||||
|
||||
if (!createTokenResponse.ok) return null;
|
||||
|
||||
const tokenData = await createTokenResponse.json();
|
||||
|
||||
return {
|
||||
token: tokenData.data.authToken,
|
||||
userId: currentUser._id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting server-side Rocket.Chat token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -10,12 +70,33 @@ export default async function Page() {
|
||||
redirect("/signin");
|
||||
}
|
||||
|
||||
// Try to get Rocket.Chat tokens server-side
|
||||
let rocketChatUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '';
|
||||
|
||||
if (session.user?.email) {
|
||||
const rocketTokens = await getRocketChatTokensServer(session.user.email);
|
||||
|
||||
if (rocketTokens) {
|
||||
// Add token to URL for direct authentication
|
||||
// Note: This is only for development/testing - in production,
|
||||
// consider more secure methods
|
||||
const urlObj = new URL(rocketChatUrl);
|
||||
urlObj.searchParams.set('resumeToken', rocketTokens.token);
|
||||
urlObj.searchParams.set('rc_uid', rocketTokens.userId);
|
||||
urlObj.searchParams.set('rc_token', rocketTokens.token);
|
||||
rocketChatUrl = urlObj.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="w-full h-screen bg-black">
|
||||
{/* Keep RocketChatAuth for client-side backup authentication */}
|
||||
<RocketChatAuth />
|
||||
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
src={rocketChatUrl}
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_RADIO_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,47 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import { useEffect } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { clearAuthCookies } from "@/lib/session";
|
||||
|
||||
export default function SignIn() {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const error = searchParams.get("error");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger Keycloak sign-in
|
||||
signIn("keycloak", { callbackUrl: "/" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user && !session.user.nextcloudInitialized) {
|
||||
// Initialize Nextcloud
|
||||
fetch('/api/nextcloud/init', {
|
||||
method: 'POST'
|
||||
}).then(response => {
|
||||
if (!response.ok) {
|
||||
console.error('Failed to initialize Nextcloud');
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error initializing Nextcloud:', error);
|
||||
});
|
||||
// Always clear cookies on signin page load to ensure fresh authentication
|
||||
clearAuthCookies();
|
||||
|
||||
// Set error message if present
|
||||
if (error) {
|
||||
console.log("Clearing auth cookies due to error:", error);
|
||||
|
||||
if (error === "RefreshTokenError" || error === "invalid_grant") {
|
||||
setMessage("Your session has expired. Please sign in again.");
|
||||
} else {
|
||||
setMessage("There was a problem with authentication. Please sign in.");
|
||||
}
|
||||
}
|
||||
}, [session]);
|
||||
}, [error]);
|
||||
|
||||
// Login function with callbackUrl to maintain original destination
|
||||
const handleSignIn = () => {
|
||||
// Get the callback URL from the query parameters or use the home page
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/";
|
||||
|
||||
// Add a timestamp parameter to avoid caching issues
|
||||
const timestamp = new Date().getTime();
|
||||
const authParams = {
|
||||
callbackUrl,
|
||||
redirect: true,
|
||||
// Adding a timestamp to force Keycloak to skip any cached session
|
||||
authParams: {
|
||||
prompt: "login",
|
||||
t: timestamp.toString()
|
||||
}
|
||||
};
|
||||
|
||||
signIn("keycloak", authParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: "url('/signin.jpg')",
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat'
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-white">
|
||||
Redirecting to login...
|
||||
</h2>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg">
|
||||
<h1 className="text-2xl font-bold text-center mb-6">Sign In</h1>
|
||||
|
||||
{message && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSignIn}
|
||||
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded"
|
||||
>
|
||||
Sign in with Keycloak
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
56
app/silent-refresh/page.tsx
Normal file
56
app/silent-refresh/page.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
export default function SilentRefresh() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
// Notify parent window of authentication status
|
||||
const notifyParent = (statusType: string) => {
|
||||
try {
|
||||
// Post message to parent window
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: 'AUTH_STATUS',
|
||||
status: statusType,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
|
||||
console.log(`Silent refresh: notified parent of ${statusType} status`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error notifying parent window:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// When session status changes, notify parent
|
||||
if (status === 'authenticated' && session) {
|
||||
// User is authenticated
|
||||
notifyParent('AUTHENTICATED');
|
||||
} else if (status === 'unauthenticated') {
|
||||
// User is not authenticated
|
||||
notifyParent('UNAUTHENTICATED');
|
||||
}
|
||||
|
||||
// Set up automatic cleanup
|
||||
const timeout = setTimeout(() => {
|
||||
// Notify parent we're cleaning up (in case we're in loading state forever)
|
||||
notifyParent('CLEANUP');
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [session, status]);
|
||||
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<h1 className="text-lg font-medium">Silent Authentication Check</h1>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{status === 'loading' && 'Checking authentication status...'}
|
||||
{status === 'authenticated' && 'You are authenticated.'}
|
||||
{status === 'unauthenticated' && 'You are not authenticated.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||
import ResponsiveIframe from "@/app/components/responsive-iframe";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -15,7 +15,7 @@ export default async function Page() {
|
||||
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||
<ResponsiveIframe
|
||||
src={process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || ''}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,587 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Paperclip, X } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { decodeComposeContent, encodeComposeContent } from '@/lib/compose-mime-decoder';
|
||||
import { Email } from '@/app/courrier/page';
|
||||
import mime from 'mime';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { decodeEmail } from '@/lib/mail-parser-wrapper';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface ComposeEmailProps {
|
||||
showCompose: boolean;
|
||||
setShowCompose: (show: boolean) => void;
|
||||
composeTo: string;
|
||||
setComposeTo: (to: string) => void;
|
||||
composeCc: string;
|
||||
setComposeCc: (cc: string) => void;
|
||||
composeBcc: string;
|
||||
setComposeBcc: (bcc: string) => void;
|
||||
composeSubject: string;
|
||||
setComposeSubject: (subject: string) => void;
|
||||
composeBody: string;
|
||||
setComposeBody: (body: string) => void;
|
||||
showCc: boolean;
|
||||
setShowCc: (show: boolean) => void;
|
||||
showBcc: boolean;
|
||||
setShowBcc: (show: boolean) => void;
|
||||
attachments: any[];
|
||||
setAttachments: (attachments: any[]) => void;
|
||||
handleSend: () => Promise<void>;
|
||||
originalEmail?: {
|
||||
content: string;
|
||||
type: 'reply' | 'reply-all' | 'forward';
|
||||
};
|
||||
onSend: (email: Email) => void;
|
||||
onCancel: () => void;
|
||||
onBodyChange?: (body: string) => void;
|
||||
initialTo?: string;
|
||||
initialSubject?: string;
|
||||
initialBody?: string;
|
||||
initialCc?: string;
|
||||
initialBcc?: string;
|
||||
replyTo?: Email | null;
|
||||
forwardFrom?: Email | null;
|
||||
}
|
||||
|
||||
export default function ComposeEmail({
|
||||
showCompose,
|
||||
setShowCompose,
|
||||
composeTo,
|
||||
setComposeTo,
|
||||
composeCc,
|
||||
setComposeCc,
|
||||
composeBcc,
|
||||
setComposeBcc,
|
||||
composeSubject,
|
||||
setComposeSubject,
|
||||
composeBody,
|
||||
setComposeBody,
|
||||
showCc,
|
||||
setShowCc,
|
||||
showBcc,
|
||||
setShowBcc,
|
||||
attachments,
|
||||
setAttachments,
|
||||
handleSend,
|
||||
originalEmail,
|
||||
onSend,
|
||||
onCancel,
|
||||
onBodyChange,
|
||||
initialTo,
|
||||
initialSubject,
|
||||
initialBody,
|
||||
initialCc,
|
||||
initialBcc,
|
||||
replyTo,
|
||||
forwardFrom
|
||||
}: ComposeEmailProps) {
|
||||
const composeBodyRef = useRef<HTMLDivElement>(null);
|
||||
const [localContent, setLocalContent] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (replyTo || forwardFrom) {
|
||||
const initializeContent = async () => {
|
||||
if (!composeBodyRef.current) return;
|
||||
|
||||
try {
|
||||
const emailToProcess = replyTo || forwardFrom;
|
||||
console.log('[DEBUG] Initializing compose content with email:',
|
||||
emailToProcess ? {
|
||||
id: emailToProcess.id,
|
||||
subject: emailToProcess.subject,
|
||||
hasContent: !!emailToProcess.content,
|
||||
contentLength: emailToProcess.content ? emailToProcess.content.length : 0,
|
||||
preview: emailToProcess.preview
|
||||
} : 'null'
|
||||
);
|
||||
|
||||
// Set initial loading state
|
||||
composeBodyRef.current.innerHTML = `
|
||||
<div class="compose-area" contenteditable="true">
|
||||
<br/>
|
||||
<div class="text-gray-500">Loading original message...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Check if email object exists
|
||||
if (!emailToProcess) {
|
||||
console.error('[DEBUG] No email to process for reply/forward');
|
||||
composeBodyRef.current.innerHTML = `
|
||||
<div class="compose-area" contenteditable="true">
|
||||
<br/>
|
||||
<div style="color: #ef4444;">No email selected for reply/forward.</div>
|
||||
</div>
|
||||
`;
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we need to fetch full content first
|
||||
if (!emailToProcess.content || emailToProcess.content.length === 0) {
|
||||
console.log('[DEBUG] Need to fetch content before composing reply/forward');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/courrier/${emailToProcess.id}?folder=${encodeURIComponent(emailToProcess.folder || 'INBOX')}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch email content: ${response.status}`);
|
||||
}
|
||||
|
||||
const fullContent = await response.json();
|
||||
|
||||
// Update the email content with the fetched full content
|
||||
emailToProcess.content = fullContent.content;
|
||||
emailToProcess.contentFetched = true;
|
||||
|
||||
console.log('[DEBUG] Successfully fetched content for reply/forward');
|
||||
} catch (error) {
|
||||
console.error('[DEBUG] Error fetching content for reply:', error);
|
||||
composeBodyRef.current.innerHTML = `
|
||||
<div class="compose-area" contenteditable="true">
|
||||
<br/>
|
||||
<div style="color: #ef4444;">Failed to load email content. Please try again.</div>
|
||||
</div>
|
||||
`;
|
||||
setIsLoading(false);
|
||||
return; // Exit if we couldn't get the content
|
||||
}
|
||||
}
|
||||
|
||||
// Use the exact same implementation as Panel 3's ReplyContent
|
||||
try {
|
||||
const decoded = await decodeEmail(emailToProcess.content);
|
||||
|
||||
let formattedContent = '';
|
||||
|
||||
if (forwardFrom) {
|
||||
// Create a clean header for the forwarded email
|
||||
const headerHtml = `
|
||||
<div style="border-bottom: 1px solid #e2e2e2; margin-bottom: 15px; padding-bottom: 15px; font-family: Arial, sans-serif;">
|
||||
<p style="margin: 4px 0;">---------- Forwarded message ---------</p>
|
||||
<p style="margin: 4px 0;"><b>From:</b> ${decoded.from || ''}</p>
|
||||
<p style="margin: 4px 0;"><b>Date:</b> ${formatDate(decoded.date)}</p>
|
||||
<p style="margin: 4px 0;"><b>Subject:</b> ${decoded.subject || ''}</p>
|
||||
<p style="margin: 4px 0;"><b>To:</b> ${decoded.to || ''}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Use the original HTML as-is without DOMPurify or any modification
|
||||
formattedContent = `
|
||||
${headerHtml}
|
||||
${decoded.html || decoded.text || 'No content available'}
|
||||
`;
|
||||
} else {
|
||||
formattedContent = `
|
||||
<div class="quoted-message">
|
||||
<p>On ${formatDate(decoded.date)}, ${decoded.from || ''} wrote:</p>
|
||||
<blockquote>
|
||||
<div class="email-content prose prose-sm max-w-none dark:prose-invert">
|
||||
${decoded.html || `<pre>${decoded.text || ''}</pre>`}
|
||||
</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Set the content in the compose area with proper structure
|
||||
const wrappedContent = `
|
||||
<div class="compose-area" contenteditable="true" style="min-height: 100px; padding: 10px;">
|
||||
<div style="min-height: 20px;"><br/></div>
|
||||
${formattedContent}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (composeBodyRef.current) {
|
||||
composeBodyRef.current.innerHTML = wrappedContent;
|
||||
|
||||
// Place cursor at the beginning before the quoted content
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
const firstDiv = composeBodyRef.current.querySelector('div[style*="min-height: 20px;"]');
|
||||
if (firstDiv) {
|
||||
range.setStart(firstDiv, 0);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
(firstDiv as HTMLElement).focus();
|
||||
}
|
||||
|
||||
// Update compose state
|
||||
setComposeBody(wrappedContent);
|
||||
setLocalContent(wrappedContent);
|
||||
console.log('[DEBUG] Successfully set compose content');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DEBUG] Error parsing email for compose:', error);
|
||||
|
||||
// Fallback to basic content display
|
||||
const errorContent = `
|
||||
<div class="compose-area" contenteditable="true">
|
||||
<br/>
|
||||
<div style="color: #64748b;">
|
||||
---------- Original Message ---------<br/>
|
||||
${emailToProcess.subject ? `Subject: ${emailToProcess.subject}<br/>` : ''}
|
||||
${emailToProcess.from ? `From: ${emailToProcess.from}<br/>` : ''}
|
||||
${emailToProcess.date ? `Date: ${new Date(emailToProcess.date).toLocaleString()}<br/>` : ''}
|
||||
</div>
|
||||
<div style="color: #64748b; border-left: 2px solid #e5e7eb; padding-left: 10px; margin: 10px 0;">
|
||||
${emailToProcess.preview || 'No content available'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (composeBodyRef.current) {
|
||||
composeBodyRef.current.innerHTML = errorContent;
|
||||
setComposeBody(errorContent);
|
||||
setLocalContent(errorContent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DEBUG] Error initializing compose content:', error);
|
||||
if (composeBodyRef.current) {
|
||||
const errorContent = `
|
||||
<div class="compose-area" contenteditable="true">
|
||||
<br/>
|
||||
<div style="color: #ef4444;">Error loading original message.</div>
|
||||
<div style="color: #64748b; font-size: 0.875rem; margin-top: 0.5rem;">
|
||||
Technical details: ${error instanceof Error ? error.message : 'Unknown error'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
composeBodyRef.current.innerHTML = errorContent;
|
||||
setComposeBody(errorContent);
|
||||
setLocalContent(errorContent);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeContent();
|
||||
}
|
||||
}, [replyTo, forwardFrom, setComposeBody]);
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
|
||||
if (!e.currentTarget) return;
|
||||
const content = e.currentTarget.innerHTML;
|
||||
if (!content.trim()) {
|
||||
setLocalContent('');
|
||||
setComposeBody('');
|
||||
} else {
|
||||
setLocalContent(content);
|
||||
setComposeBody(content);
|
||||
}
|
||||
|
||||
if (onBodyChange) {
|
||||
onBodyChange(content);
|
||||
}
|
||||
|
||||
// Ensure scrolling and cursor behavior works after edits
|
||||
const messageContentDivs = e.currentTarget.querySelectorAll('.message-content');
|
||||
messageContentDivs.forEach(div => {
|
||||
// Make sure the div remains scrollable after input events
|
||||
(div as HTMLElement).style.maxHeight = '300px';
|
||||
(div as HTMLElement).style.overflowY = 'auto';
|
||||
(div as HTMLElement).style.border = '1px solid #e5e7eb';
|
||||
(div as HTMLElement).style.borderRadius = '4px';
|
||||
(div as HTMLElement).style.padding = '10px';
|
||||
|
||||
// Ensure wheel events are properly handled
|
||||
if (!(div as HTMLElement).hasAttribute('data-scroll-handler-attached')) {
|
||||
div.addEventListener('wheel', function(this: HTMLElement, ev: Event) {
|
||||
const e = ev as WheelEvent;
|
||||
const target = this;
|
||||
|
||||
// Check if we're at the boundary of the scrollable area
|
||||
const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 1;
|
||||
const isAtTop = target.scrollTop <= 0;
|
||||
|
||||
// Only prevent default if we're not at the boundaries in the direction of scrolling
|
||||
if ((e.deltaY > 0 && !isAtBottom) || (e.deltaY < 0 && !isAtTop)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault(); // Prevent the parent container from scrolling
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Mark this element as having a scroll handler attached
|
||||
(div as HTMLElement).setAttribute('data-scroll-handler-attached', 'true');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!composeBodyRef.current) return;
|
||||
|
||||
const composeArea = composeBodyRef.current.querySelector('.compose-area');
|
||||
if (!composeArea) return;
|
||||
|
||||
const content = composeArea.innerHTML;
|
||||
if (!content.trim()) {
|
||||
console.error('Email content is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedContent = await encodeComposeContent(content);
|
||||
setComposeBody(encodedContent);
|
||||
await handleSend();
|
||||
setShowCompose(false);
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileAttachment = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files) return;
|
||||
|
||||
const newAttachments: any[] = [];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB in bytes
|
||||
const oversizedFiles: string[] = [];
|
||||
|
||||
for (const file of e.target.files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
oversizedFiles.push(file.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read file as base64
|
||||
const base64Content = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
resolve(base64.split(',')[1]); // Remove data URL prefix
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
newAttachments.push({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
content: base64Content,
|
||||
encoding: 'base64'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing attachment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (oversizedFiles.length > 0) {
|
||||
alert(`The following files exceed the 10MB size limit and were not attached:\n${oversizedFiles.join('\n')}`);
|
||||
}
|
||||
|
||||
if (newAttachments.length > 0) {
|
||||
setAttachments([...attachments, ...newAttachments]);
|
||||
}
|
||||
};
|
||||
|
||||
// Add focus handling for better UX
|
||||
const handleComposeAreaClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// If the click is directly on the compose area and not on any child element
|
||||
if (e.target === e.currentTarget) {
|
||||
// Find the cursor position element
|
||||
const cursorPosition = e.currentTarget.querySelector('.cursor-position');
|
||||
if (cursorPosition) {
|
||||
// Focus the cursor position element
|
||||
(cursorPosition as HTMLElement).focus();
|
||||
|
||||
// Set cursor at the beginning
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(cursorPosition, 0);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add formatDate function to match Panel 3 implementation
|
||||
function formatDate(date: Date | null): string {
|
||||
if (!date) return '';
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
if (!showCompose) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600/30 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="w-full max-w-2xl h-[90vh] bg-white rounded-xl shadow-xl flex flex-col mx-4">
|
||||
{/* Modal Header */}
|
||||
<div className="flex-none flex items-center justify-between px-6 py-3 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{replyTo ? 'Reply' : forwardFrom ? 'Forward' : 'New Message'}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-gray-100 rounded-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full flex flex-col p-6 space-y-4 overflow-y-auto">
|
||||
{/* To Field */}
|
||||
<div className="flex-none">
|
||||
<Label htmlFor="to" className="block text-sm font-medium text-gray-700">To</Label>
|
||||
<Input
|
||||
id="to"
|
||||
value={composeTo}
|
||||
onChange={(e) => setComposeTo(e.target.value)}
|
||||
placeholder="recipient@example.com"
|
||||
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CC/BCC Toggle Buttons */}
|
||||
<div className="flex-none flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
onClick={() => setShowCc(!showCc)}
|
||||
>
|
||||
{showCc ? 'Hide Cc' : 'Add Cc'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
onClick={() => setShowBcc(!showBcc)}
|
||||
>
|
||||
{showBcc ? 'Hide Bcc' : 'Add Bcc'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CC Field */}
|
||||
{showCc && (
|
||||
<div className="flex-none">
|
||||
<Label htmlFor="cc" className="block text-sm font-medium text-gray-700">Cc</Label>
|
||||
<Input
|
||||
id="cc"
|
||||
value={composeCc}
|
||||
onChange={(e) => setComposeCc(e.target.value)}
|
||||
placeholder="cc@example.com"
|
||||
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BCC Field */}
|
||||
{showBcc && (
|
||||
<div className="flex-none">
|
||||
<Label htmlFor="bcc" className="block text-sm font-medium text-gray-700">Bcc</Label>
|
||||
<Input
|
||||
id="bcc"
|
||||
value={composeBcc}
|
||||
onChange={(e) => setComposeBcc(e.target.value)}
|
||||
placeholder="bcc@example.com"
|
||||
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subject Field */}
|
||||
<div className="flex-none">
|
||||
<Label htmlFor="subject" className="block text-sm font-medium text-gray-700">Subject</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={composeSubject}
|
||||
onChange={(e) => setComposeSubject(e.target.value)}
|
||||
placeholder="Enter subject"
|
||||
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message Body */}
|
||||
<div className="flex-1 min-h-[200px] flex flex-col">
|
||||
<Label htmlFor="message" className="flex-none block text-sm font-medium text-gray-700 mb-2">Message</Label>
|
||||
<div
|
||||
ref={composeBodyRef}
|
||||
contentEditable="true"
|
||||
onInput={handleInput}
|
||||
onClick={handleComposeAreaClick}
|
||||
className="flex-1 w-full bg-white border border-gray-300 rounded-md p-4 text-black overflow-y-auto focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
style={{
|
||||
direction: 'ltr',
|
||||
maxHeight: 'calc(100vh - 400px)',
|
||||
minHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#cbd5e0 #f3f4f6',
|
||||
cursor: 'text'
|
||||
}}
|
||||
dir="ltr"
|
||||
spellCheck="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
tabIndex={0}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex-none flex items-center justify-between px-6 py-3 border-t border-gray-200 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* File Input for Attachments */}
|
||||
<input
|
||||
type="file"
|
||||
id="file-attachment"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFileAttachment}
|
||||
/>
|
||||
<label htmlFor="file-attachment">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full bg-white hover:bg-gray-100 border-gray-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById('file-attachment')?.click();
|
||||
}}
|
||||
>
|
||||
<Paperclip className="h-4 w-4 text-gray-600" />
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-gray-600 hover:text-gray-700 hover:bg-gray-100"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={handleSendEmail}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,23 +5,31 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AuthCheck({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession();
|
||||
const { status } = useSession();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated" && pathname !== "/signin") {
|
||||
// Simple redirect to login page if not authenticated
|
||||
if (status === "unauthenticated" && !pathname.includes("/signin")) {
|
||||
router.push("/signin");
|
||||
}
|
||||
}, [status, router, pathname]);
|
||||
|
||||
// Simple loading state
|
||||
if (status === "loading") {
|
||||
return <div>Chargement...</div>;
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<div className="animate-spin h-10 w-10 border-4 border-blue-500 rounded-full border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated" && pathname !== "/signin") {
|
||||
// Don't render on unauthenticated
|
||||
if (status === "unauthenticated" && !pathname.includes("/signin")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render children if authenticated
|
||||
return <>{children}</>;
|
||||
}
|
||||
@ -1,24 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { clearAuthCookies } from "@/lib/session";
|
||||
|
||||
export function SignOutHandler() {
|
||||
const { data: session } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
const handleSignOut = async () => {
|
||||
// Clear only auth-related cookies
|
||||
clearAuthCookies();
|
||||
|
||||
// Then sign out from NextAuth
|
||||
await signOut({
|
||||
callbackUrl: "/signin",
|
||||
redirect: true
|
||||
});
|
||||
try {
|
||||
// Store the user ID before signout clears the session
|
||||
const userId = session?.user?.id;
|
||||
console.log('Starting optimized logout process (preserving SSO)');
|
||||
|
||||
// First trigger server-side Redis cleanup only
|
||||
if (userId) {
|
||||
console.log('Triggering server-side Redis cleanup');
|
||||
try {
|
||||
const cleanupResponse = await fetch('/api/auth/session-cleanup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
preserveSso: true
|
||||
}),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const cleanupResult = await cleanupResponse.json();
|
||||
console.log('Server cleanup result:', cleanupResult);
|
||||
} catch (cleanupError) {
|
||||
console.error('Error during server-side cleanup:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
// Use NextAuth signOut but ONLY for our application cookies
|
||||
// This preserves the Keycloak SSO session for other services
|
||||
await signOut({ redirect: false });
|
||||
|
||||
// Clear ONLY application-specific cookies (not Keycloak SSO cookies)
|
||||
const appCookies = [
|
||||
'next-auth.session-token',
|
||||
'next-auth.csrf-token',
|
||||
'next-auth.callback-url',
|
||||
'__Secure-next-auth.session-token',
|
||||
'__Host-next-auth.csrf-token',
|
||||
];
|
||||
|
||||
// Only clear chunked cookies for our app
|
||||
const cookies = document.cookie.split(';');
|
||||
const chunkedCookies = cookies
|
||||
.map(cookie => cookie.split('=')[0].trim())
|
||||
.filter(name => /next-auth.*\.\d+$/.test(name));
|
||||
|
||||
[...appCookies, ...chunkedCookies].forEach(cookieName => {
|
||||
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure`;
|
||||
});
|
||||
|
||||
// Clear Rocket Chat authentication tokens
|
||||
try {
|
||||
console.log('Clearing Rocket Chat tokens');
|
||||
// Remove cookies
|
||||
document.cookie = `rc_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure`;
|
||||
document.cookie = `rc_uid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure`;
|
||||
|
||||
// Remove localStorage items
|
||||
localStorage.removeItem('Meteor.loginToken');
|
||||
localStorage.removeItem('Meteor.userId');
|
||||
} catch (e) {
|
||||
console.error('Error clearing Rocket Chat tokens:', e);
|
||||
}
|
||||
|
||||
// Redirect to loggedout page
|
||||
window.location.href = '/loggedout?preserveSso=true';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
window.location.href = '/loggedout?preserveSso=true';
|
||||
}
|
||||
};
|
||||
|
||||
handleSignOut();
|
||||
}, []);
|
||||
// Add a slight delay to ensure useSession has loaded
|
||||
const timer = setTimeout(() => {
|
||||
handleSignOut();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [session]);
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">Logging out...</h2>
|
||||
<p className="text-gray-500 mt-2">Please wait while we sign you out of this application.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { format, isToday, isTomorrow, addDays } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { CalendarIcon, ClockIcon, ChevronRight } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
isAllDay: boolean;
|
||||
calendarId: string;
|
||||
calendarName?: string;
|
||||
calendarColor?: string;
|
||||
};
|
||||
|
||||
export function CalendarWidget() {
|
||||
const { data: session } = useSession();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Ne charger les événements que si l'utilisateur est connecté
|
||||
if (!session) return;
|
||||
|
||||
const fetchUpcomingEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Récupérer d'abord les calendriers de l'utilisateur
|
||||
const calendarsRes = await fetch("/api/calendars");
|
||||
|
||||
if (!calendarsRes.ok) {
|
||||
throw new Error("Impossible de charger les calendriers");
|
||||
}
|
||||
|
||||
const calendars = await calendarsRes.json();
|
||||
|
||||
if (calendars.length === 0) {
|
||||
setEvents([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Date actuelle et date dans 7 jours
|
||||
const now = new Date();
|
||||
// @ts-ignore
|
||||
const nextWeek = addDays(now, 7);
|
||||
|
||||
// Récupérer les événements pour chaque calendrier
|
||||
const allEventsPromises = calendars.map(async (calendar: any) => {
|
||||
const eventsRes = await fetch(
|
||||
`/api/calendars/${
|
||||
calendar.id
|
||||
}/events?start=${now.toISOString()}&end=${nextWeek.toISOString()}`
|
||||
);
|
||||
|
||||
if (!eventsRes.ok) {
|
||||
console.warn(
|
||||
`Impossible de charger les événements du calendrier ${calendar.id}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const events = await eventsRes.json();
|
||||
|
||||
// Ajouter les informations du calendrier à chaque événement
|
||||
return events.map((event: any) => ({
|
||||
...event,
|
||||
calendarName: calendar.name,
|
||||
calendarColor: calendar.color,
|
||||
}));
|
||||
});
|
||||
|
||||
// Attendre toutes les requêtes d'événements
|
||||
const allEventsArrays = await Promise.all(allEventsPromises);
|
||||
|
||||
// Fusionner tous les événements en un seul tableau
|
||||
const allEvents = allEventsArrays.flat();
|
||||
|
||||
// Trier par date de début
|
||||
const sortedEvents = allEvents.sort(
|
||||
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()
|
||||
);
|
||||
|
||||
// Limiter à 5 événements
|
||||
setEvents(sortedEvents.slice(0, 5));
|
||||
} catch (err) {
|
||||
console.error("Erreur lors du chargement des événements:", err);
|
||||
setError("Impossible de charger les événements à venir");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUpcomingEvents();
|
||||
}, [session]);
|
||||
|
||||
// Formater la date d'un événement pour l'affichage
|
||||
const formatEventDate = (date: string, isAllDay: boolean) => {
|
||||
const eventDate = new Date(date);
|
||||
|
||||
let dateString = "";
|
||||
// @ts-ignore
|
||||
if (isToday(eventDate)) {
|
||||
dateString = "Aujourd'hui";
|
||||
// @ts-ignore
|
||||
} else if (isTomorrow(eventDate)) {
|
||||
dateString = "Demain";
|
||||
} else {
|
||||
// @ts-ignore
|
||||
dateString = format(eventDate, "EEEE d MMMM", { locale: fr });
|
||||
}
|
||||
|
||||
if (!isAllDay) {
|
||||
// @ts-ignore
|
||||
dateString += ` · ${format(eventDate, "HH:mm", { locale: fr })}`;
|
||||
}
|
||||
|
||||
return dateString;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='transition-transform duration-500 ease-in-out transform hover:scale-105'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<CardTitle className='text-lg font-medium'>
|
||||
Événements à venir
|
||||
</CardTitle>
|
||||
<Link href='/calendar' passHref>
|
||||
<Button variant='ghost' size='sm' className='h-8 w-8 p-0'>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
<span className='sr-only'>Voir le calendrier</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className='pb-3'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center py-4'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent' />
|
||||
<span className='ml-2 text-sm text-muted-foreground'>
|
||||
Chargement...
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className='text-sm text-red-500'>{error}</p>
|
||||
) : events.length === 0 ? (
|
||||
<p className='text-sm text-muted-foreground py-2'>
|
||||
Aucun événement à venir cette semaine
|
||||
</p>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className='flex items-start space-x-3 rounded-md border border-muted p-2'
|
||||
>
|
||||
<div
|
||||
className='h-3 w-3 flex-shrink-0 rounded-full mt-1'
|
||||
style={{ backgroundColor: event.calendarColor || "#0082c9" }}
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h5
|
||||
className='text-sm font-medium truncate'
|
||||
title={event.title}
|
||||
>
|
||||
{event.title}
|
||||
</h5>
|
||||
<div className='flex items-center text-xs text-muted-foreground mt-1'>
|
||||
<CalendarIcon className='h-3 w-3 mr-1' />
|
||||
<span>{formatEventDate(event.start, event.isAllDay)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link href='/calendar' passHref>
|
||||
<Button
|
||||
size='sm'
|
||||
className='w-full transition-all ease-in-out duration-500 bg-muted text-black hover:text-white hover:bg-primary'
|
||||
>
|
||||
Voir tous les événements
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,107 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Calendar as CalendarIcon } from "lucide-react";
|
||||
import { RefreshCw, Calendar as CalendarIcon, ChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { format, isToday, isTomorrow } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useCalendarEvents, CalendarEvent } from "@/hooks/use-calendar-events";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
calendar: string;
|
||||
calendarColor: string;
|
||||
interface CalendarProps {
|
||||
limit?: number;
|
||||
showMore?: boolean;
|
||||
showRefresh?: boolean;
|
||||
cardClassName?: string;
|
||||
}
|
||||
|
||||
export function Calendar() {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export function Calendar({
|
||||
limit = 5,
|
||||
showMore = true,
|
||||
showRefresh = true,
|
||||
cardClassName = "transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg"
|
||||
}: CalendarProps) {
|
||||
const { events, loading, error, refresh } = useCalendarEvents({ limit });
|
||||
const router = useRouter();
|
||||
|
||||
const fetchEvents = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/calendars');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch events');
|
||||
}
|
||||
|
||||
const calendarsData = await response.json();
|
||||
console.log('Calendar Widget - Fetched calendars:', calendarsData);
|
||||
const formatEventDate = (date: Date | string, isAllDay: boolean) => {
|
||||
const eventDate = date instanceof Date ? date : new Date(date);
|
||||
let dateString = "";
|
||||
|
||||
// Get current date at the start of the day
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
// Extract and process events from all calendars
|
||||
const allEvents = calendarsData.flatMap((calendar: any) =>
|
||||
(calendar.events || []).map((event: any) => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
allDay: event.isAllDay,
|
||||
calendar: calendar.name,
|
||||
calendarColor: calendar.color
|
||||
}))
|
||||
);
|
||||
|
||||
// Filter for upcoming events
|
||||
const upcomingEvents = allEvents
|
||||
.filter((event: any) => new Date(event.start) >= now)
|
||||
.sort((a: any, b: any) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
||||
.slice(0, 7);
|
||||
|
||||
console.log('Calendar Widget - Processed events:', upcomingEvents);
|
||||
setEvents(upcomingEvents);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching events:', err);
|
||||
setError('Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isToday(eventDate)) {
|
||||
dateString = "Today";
|
||||
} else if (isTomorrow(eventDate)) {
|
||||
dateString = "Tomorrow";
|
||||
} else {
|
||||
dateString = format(eventDate, "EEEE d MMMM", { locale: fr });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
if (!isAllDay) {
|
||||
dateString += ` · ${format(eventDate, "HH:mm", { locale: fr })}`;
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'short'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
return dateString;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
|
||||
<Card className={cardClassName}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<CalendarIcon className="h-5 w-5 text-gray-600" />
|
||||
Agenda
|
||||
Calendar
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fetchEvents()}
|
||||
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{showRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={refresh}
|
||||
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
|
||||
</Button>
|
||||
)}
|
||||
{showMore && (
|
||||
<Link href='/agenda' passHref>
|
||||
<Button variant='ghost' size='sm' className='h-8 w-8 p-0'>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
<span className='sr-only'>View calendar</span>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading ? (
|
||||
@ -123,21 +93,21 @@ export function Calendar() {
|
||||
<div
|
||||
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center border"
|
||||
style={{
|
||||
backgroundColor: `${event.calendarColor}10`,
|
||||
borderColor: event.calendarColor
|
||||
backgroundColor: `${event.calendarColor || '#4F46E5'}10`,
|
||||
borderColor: event.calendarColor || '#4F46E5'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: event.calendarColor }}
|
||||
style={{ color: event.calendarColor || '#4F46E5' }}
|
||||
>
|
||||
{formatDate(event.start)}
|
||||
{format(event.start instanceof Date ? event.start : new Date(event.start), 'MMM', { locale: fr })}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-bold mt-0.5"
|
||||
style={{ color: event.calendarColor }}
|
||||
style={{ color: event.calendarColor || '#4F46E5' }}
|
||||
>
|
||||
{formatTime(event.start)}
|
||||
{format(event.start instanceof Date ? event.start : new Date(event.start), 'dd', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
@ -145,25 +115,36 @@ export function Calendar() {
|
||||
<p className="text-sm font-medium text-gray-800 line-clamp-2 flex-1">
|
||||
{event.title}
|
||||
</p>
|
||||
{!event.allDay && (
|
||||
{!event.isAllDay && (
|
||||
<span className="text-[10px] text-gray-500 whitespace-nowrap">
|
||||
{formatTime(event.start)} - {formatTime(event.end)}
|
||||
{format(event.start instanceof Date ? event.start : new Date(event.start), 'HH:mm', { locale: fr })} - {format(event.end instanceof Date ? event.end : new Date(event.end), 'HH:mm', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center text-[10px] px-1.5 py-0.5 rounded-md"
|
||||
style={{
|
||||
backgroundColor: `${event.calendarColor}10`,
|
||||
color: event.calendarColor
|
||||
backgroundColor: `${event.calendarColor || '#4F46E5'}10`,
|
||||
color: event.calendarColor || '#4F46E5'
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{event.calendar}</span>
|
||||
<span className="truncate">{event.calendarName || 'Calendar'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showMore && (
|
||||
<Link href='/agenda' passHref>
|
||||
<Button
|
||||
size='sm'
|
||||
className='w-full transition-all ease-in-out duration-500 bg-gray-100 text-gray-700 hover:bg-gray-200 mt-2'
|
||||
>
|
||||
View all events
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
49
components/debug/RedisCacheStatus.tsx
Normal file
49
components/debug/RedisCacheStatus.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Debug component to show Redis connection status
|
||||
* Only visible in development mode
|
||||
*/
|
||||
export function RedisCacheStatus() {
|
||||
const [status, setStatus] = useState<'connected' | 'error' | 'loading'>('loading');
|
||||
const [lastCheck, setLastCheck] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
async function checkRedis() {
|
||||
try {
|
||||
setStatus('loading');
|
||||
const response = await fetch('/api/redis/status');
|
||||
const data = await response.json();
|
||||
setStatus(data.status);
|
||||
setLastCheck(new Date().toLocaleTimeString());
|
||||
} catch (e) {
|
||||
setStatus('error');
|
||||
setLastCheck(new Date().toLocaleTimeString());
|
||||
}
|
||||
}
|
||||
|
||||
checkRedis();
|
||||
const interval = setInterval(checkRedis, 30000); // Check every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Only show in development mode
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 text-xs bg-gray-800/80 text-white p-2 rounded shadow-md z-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
status === 'connected' ? 'bg-green-500' :
|
||||
status === 'loading' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}></div>
|
||||
<span>Redis: {status}</span>
|
||||
<span className="opacity-60">({lastCheck})</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, MessageSquare, Mail, MailOpen, Loader2 } from "lucide-react";
|
||||
import Link from 'next/link';
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
interface Email {
|
||||
id: string;
|
||||
@ -17,28 +18,43 @@ interface Email {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
interface EmailResponse {
|
||||
emails: Email[];
|
||||
mailUrl: string;
|
||||
error?: string;
|
||||
interface EmailProps {
|
||||
limit?: number;
|
||||
showTitle?: boolean;
|
||||
showRefresh?: boolean;
|
||||
showMoreLink?: boolean;
|
||||
folder?: string;
|
||||
cardClassName?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Email() {
|
||||
export function Email({
|
||||
limit = 5,
|
||||
showTitle = true,
|
||||
showRefresh = true,
|
||||
showMoreLink = true,
|
||||
folder = "INBOX",
|
||||
cardClassName = "h-full",
|
||||
title = "Unread Emails"
|
||||
}: EmailProps) {
|
||||
const [emails, setEmails] = useState<Email[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mailUrl, setMailUrl] = useState<string | null>(null);
|
||||
const { status } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmails();
|
||||
}, []);
|
||||
if (status === "authenticated") {
|
||||
fetchEmails();
|
||||
}
|
||||
}, [status, folder, limit]);
|
||||
|
||||
const fetchEmails = async (isRefresh = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/courrier?folder=INBOX&page=1&perPage=5');
|
||||
const response = await fetch(`/api/courrier?folder=${folder}&page=1&perPage=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch emails');
|
||||
}
|
||||
@ -52,20 +68,19 @@ export function Email() {
|
||||
// Transform data format if needed
|
||||
const transformedEmails = data.emails.map((email: any) => ({
|
||||
id: email.id,
|
||||
subject: email.subject,
|
||||
subject: email.subject || '(No subject)',
|
||||
from: email.from[0]?.address || '',
|
||||
fromName: email.from[0]?.name || '',
|
||||
date: email.date,
|
||||
read: email.flags.seen,
|
||||
starred: email.flags.flagged,
|
||||
folder: email.folder
|
||||
})).slice(0, 5); // Only show the first 5 emails
|
||||
})).slice(0, limit);
|
||||
|
||||
setEmails(transformedEmails);
|
||||
setMailUrl('/courrier');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching emails:', error);
|
||||
setError('Failed to load emails');
|
||||
setEmails([]);
|
||||
} finally {
|
||||
@ -85,68 +100,88 @@ export function Email() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-gray-600" />
|
||||
Emails non lus
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fetchEmails(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{error ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{error}
|
||||
</div>
|
||||
) : loading && emails.length === 0 ? (
|
||||
<div className="text-center py-6 flex flex-col items-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
|
||||
<p className="text-gray-500">Chargement des emails...</p>
|
||||
</div>
|
||||
) : emails.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-gray-500">Aucun email non lu</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{emails.map((email) => (
|
||||
<div key={email.id} className="flex items-start gap-3 py-1 border-b border-gray-100 last:border-0">
|
||||
<div className="pt-1">
|
||||
{email.read ?
|
||||
<MailOpen className="h-4 w-4 text-gray-400" /> :
|
||||
<Mail className="h-4 w-4 text-blue-500" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between">
|
||||
<p className="font-medium truncate" style={{maxWidth: '180px'}}>{email.fromName || email.from.split('@')[0]}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(email.date)}</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 truncate">{email.subject}</p>
|
||||
</div>
|
||||
const renderContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && emails.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6 flex flex-col items-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
|
||||
<p className="text-gray-500">Loading emails...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (emails.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-gray-500">No emails found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{emails.map((email) => (
|
||||
<div key={email.id} className="flex items-start gap-3 py-1 border-b border-gray-100 last:border-0">
|
||||
<div className="pt-1">
|
||||
{email.read ?
|
||||
<MailOpen className="h-4 w-4 text-gray-400" /> :
|
||||
<Mail className="h-4 w-4 text-blue-500" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between">
|
||||
<p className="font-medium truncate" style={{maxWidth: '180px'}}>{email.fromName || email.from.split('@')[0]}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(email.date)}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{mailUrl && (
|
||||
<div className="pt-2">
|
||||
<Link href={mailUrl} className="text-sm text-blue-600 hover:text-blue-800">
|
||||
Voir tous les emails →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-700 truncate">{email.subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showMoreLink && mailUrl && (
|
||||
<div className="pt-2">
|
||||
<Link href={mailUrl} className="text-sm text-blue-600 hover:text-blue-800">
|
||||
View all emails →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cardClassName}>
|
||||
{showTitle && (
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-gray-600" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
{showRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fetchEmails(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className="p-4">
|
||||
{renderContent()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
84
components/email/BulkActionsToolbar.tsx
Normal file
84
components/email/BulkActionsToolbar.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Trash2, Archive, EyeOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface BulkActionsToolbarProps {
|
||||
selectedCount: number;
|
||||
onBulkAction: (action: 'delete' | 'mark-read' | 'mark-unread' | 'archive') => void;
|
||||
}
|
||||
|
||||
export default function BulkActionsToolbar({
|
||||
selectedCount,
|
||||
onBulkAction
|
||||
}: BulkActionsToolbarProps) {
|
||||
return (
|
||||
<div className="bg-blue-50 border-b border-blue-100 px-4 py-2 flex items-center justify-between shadow-md transition-all duration-200">
|
||||
<span className="text-xs font-medium text-blue-700">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-blue-600 hover:text-blue-900 hover:bg-blue-100"
|
||||
onClick={() => onBulkAction('mark-unread')}
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Mark as unread</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-blue-600 hover:text-blue-900 hover:bg-blue-100"
|
||||
onClick={() => onBulkAction('archive')}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Archive</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => onBulkAction('delete')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
137
components/email/ComposeEmailAdapter.tsx
Normal file
137
components/email/ComposeEmailAdapter.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import ComposeEmail from './ComposeEmail';
|
||||
import { EmailMessage } from '@/types/email';
|
||||
import {
|
||||
formatReplyEmail,
|
||||
formatForwardedEmail
|
||||
} from '@/lib/utils/email-utils';
|
||||
|
||||
interface ComposeEmailAdapterProps {
|
||||
initialEmail?: EmailMessage | null;
|
||||
type?: 'new' | 'reply' | 'reply-all' | 'forward';
|
||||
onClose: () => void;
|
||||
onSend: (emailData: {
|
||||
to: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
fromAccount?: string;
|
||||
attachments?: Array<{
|
||||
name: string;
|
||||
content: string;
|
||||
type: string;
|
||||
}>;
|
||||
}) => Promise<void>;
|
||||
accounts?: Array<{
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter component that converts between the new EmailMessage format
|
||||
* and the format expected by the legacy ComposeEmail component
|
||||
*/
|
||||
export default function ComposeEmailAdapter({
|
||||
initialEmail,
|
||||
type = 'new',
|
||||
onClose,
|
||||
onSend,
|
||||
accounts = []
|
||||
}: ComposeEmailAdapterProps) {
|
||||
// Convert the new EmailMessage format to the old format
|
||||
const [adaptedEmail, setAdaptedEmail] = useState<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialEmail) {
|
||||
setAdaptedEmail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('ComposeEmailAdapter: Processing email for', type, initialEmail);
|
||||
|
||||
// For reply or forward, use the email formatting utilities
|
||||
if (type === 'reply' || type === 'reply-all') {
|
||||
const formatted = formatReplyEmail(initialEmail, type);
|
||||
console.log('ComposeEmailAdapter: Formatted reply', formatted);
|
||||
|
||||
// Create an object suitable for the legacy ComposeEmail component
|
||||
const adapted = {
|
||||
id: initialEmail.id,
|
||||
subject: formatted.subject,
|
||||
to: formatted.to,
|
||||
cc: formatted.cc || '',
|
||||
content: formatted.content?.html || formatted.content?.text || '',
|
||||
html: formatted.content?.html,
|
||||
text: formatted.content?.text,
|
||||
attachments: initialEmail.attachments || []
|
||||
};
|
||||
|
||||
setAdaptedEmail(adapted);
|
||||
}
|
||||
else if (type === 'forward') {
|
||||
const formatted = formatForwardedEmail(initialEmail);
|
||||
console.log('ComposeEmailAdapter: Formatted forward', formatted);
|
||||
|
||||
// Create an object suitable for the legacy ComposeEmail component
|
||||
const adapted = {
|
||||
id: initialEmail.id,
|
||||
subject: formatted.subject,
|
||||
to: '',
|
||||
cc: '',
|
||||
content: formatted.content?.html || formatted.content?.text || '',
|
||||
html: formatted.content?.html,
|
||||
text: formatted.content?.text,
|
||||
attachments: initialEmail.attachments || []
|
||||
};
|
||||
|
||||
setAdaptedEmail(adapted);
|
||||
}
|
||||
else {
|
||||
// For new emails, just create a minimal template
|
||||
setAdaptedEmail({
|
||||
subject: '',
|
||||
to: '',
|
||||
cc: '',
|
||||
content: '',
|
||||
html: '',
|
||||
text: '',
|
||||
attachments: []
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adapting email for ComposeEmail:', error);
|
||||
// Provide a fallback minimal email
|
||||
setAdaptedEmail({
|
||||
subject: initialEmail?.subject || '',
|
||||
to: initialEmail?.from || '',
|
||||
cc: '',
|
||||
content: '',
|
||||
html: '',
|
||||
text: '',
|
||||
attachments: []
|
||||
});
|
||||
}
|
||||
}, [initialEmail, type]);
|
||||
|
||||
// If still adapting, show loading
|
||||
if (initialEmail && !adaptedEmail) {
|
||||
return <div>Loading email...</div>;
|
||||
}
|
||||
|
||||
// Pass the adapted email to the original ComposeEmail component
|
||||
return (
|
||||
<ComposeEmail
|
||||
initialEmail={adaptedEmail}
|
||||
type={type}
|
||||
onClose={onClose}
|
||||
onSend={onSend}
|
||||
accounts={accounts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
components/email/ComposeEmailFooter.tsx
Normal file
53
components/email/ComposeEmailFooter.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { SendHorizontal, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ComposeEmailFooterProps {
|
||||
sending: boolean;
|
||||
onSend: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ComposeEmailFooter({
|
||||
sending,
|
||||
onSend,
|
||||
onCancel
|
||||
}: ComposeEmailFooterProps) {
|
||||
return (
|
||||
<div className="p-4 border-t border-gray-200 flex justify-between items-center">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
disabled={sending}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SendHorizontal className="mr-2 h-4 w-4" />
|
||||
Send
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={sending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{sending ? 'Sending your email...' : 'Ready to send'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
components/email/ComposeEmailForm.tsx
Normal file
227
components/email/ComposeEmailForm.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Paperclip, X } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface ComposeEmailFormProps {
|
||||
to: string;
|
||||
setTo: (value: string) => void;
|
||||
cc: string;
|
||||
setCc: (value: string) => void;
|
||||
bcc: string;
|
||||
setBcc: (value: string) => void;
|
||||
subject: string;
|
||||
setSubject: (value: string) => void;
|
||||
emailContent: string;
|
||||
setEmailContent: (value: string) => void;
|
||||
showCc: boolean;
|
||||
setShowCc: (value: boolean) => void;
|
||||
showBcc: boolean;
|
||||
setShowBcc: (value: boolean) => void;
|
||||
attachments: Array<{
|
||||
name: string;
|
||||
content: string;
|
||||
type: string;
|
||||
}>;
|
||||
onAttachmentAdd: (files: FileList) => void;
|
||||
onAttachmentRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
export default function ComposeEmailForm({
|
||||
to,
|
||||
setTo,
|
||||
cc,
|
||||
setCc,
|
||||
bcc,
|
||||
setBcc,
|
||||
subject,
|
||||
setSubject,
|
||||
emailContent,
|
||||
setEmailContent,
|
||||
showCc,
|
||||
setShowCc,
|
||||
showBcc,
|
||||
setShowBcc,
|
||||
attachments,
|
||||
onAttachmentAdd,
|
||||
onAttachmentRemove
|
||||
}: ComposeEmailFormProps) {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAttachmentClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onAttachmentAdd(e.target.files);
|
||||
}
|
||||
|
||||
// Reset the input value so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* To field */}
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="to" className="w-16 flex-shrink-0">To:</Label>
|
||||
<Input
|
||||
id="to"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
placeholder="Email address..."
|
||||
className="flex-grow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CC field - conditionally shown */}
|
||||
{showCc && (
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="cc" className="w-16 flex-shrink-0">Cc:</Label>
|
||||
<Input
|
||||
id="cc"
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
placeholder="CC address..."
|
||||
className="flex-grow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BCC field - conditionally shown */}
|
||||
{showBcc && (
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="bcc" className="w-16 flex-shrink-0">Bcc:</Label>
|
||||
<Input
|
||||
id="bcc"
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
placeholder="BCC address..."
|
||||
className="flex-grow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CC/BCC toggle buttons */}
|
||||
<div className="flex items-center space-x-2 ml-16">
|
||||
{!showCc && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCc(true)}
|
||||
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Add Cc <ChevronDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!showBcc && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowBcc(true)}
|
||||
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Add Bcc <ChevronDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showCc && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCc(false)}
|
||||
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Remove Cc <ChevronUp className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showBcc && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowBcc(false)}
|
||||
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Remove Bcc <ChevronUp className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subject field */}
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="subject" className="w-16 flex-shrink-0">Subject:</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Email subject..."
|
||||
className="flex-grow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email content */}
|
||||
<Textarea
|
||||
value={emailContent}
|
||||
onChange={(e) => setEmailContent(e.target.value)}
|
||||
placeholder="Write your message here..."
|
||||
className="w-full min-h-[200px]"
|
||||
/>
|
||||
|
||||
{/* Attachments */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="border rounded-md p-2">
|
||||
<h3 className="text-sm font-medium mb-2">Attachments</h3>
|
||||
<div className="space-y-2">
|
||||
{attachments.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm border rounded p-2">
|
||||
<span className="truncate max-w-[200px]">{file.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAttachmentRemove(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment input (hidden) */}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelection}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Attachments button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAttachmentClick}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Paperclip className="mr-2 h-4 w-4" />
|
||||
Attach Files
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
components/email/ComposeEmailHeader.tsx
Normal file
41
components/email/ComposeEmailHeader.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ComposeEmailHeaderProps {
|
||||
type: 'new' | 'reply' | 'reply-all' | 'forward';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ComposeEmailHeader({
|
||||
type,
|
||||
onClose
|
||||
}: ComposeEmailHeaderProps) {
|
||||
// Set the header title based on the compose type
|
||||
const getTitle = () => {
|
||||
switch (type) {
|
||||
case 'reply':
|
||||
return 'Reply';
|
||||
case 'reply-all':
|
||||
return 'Reply All';
|
||||
case 'forward':
|
||||
return 'Forward';
|
||||
default:
|
||||
return 'New Message';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{getTitle()}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
components/email/EmailContent.tsx
Normal file
135
components/email/EmailContent.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2, Paperclip, Download } from 'lucide-react';
|
||||
import { sanitizeHtml } from '@/lib/utils/dom-purify-config';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
interface EmailAddress {
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
interface Email {
|
||||
id: string;
|
||||
subject: string;
|
||||
from: EmailAddress[];
|
||||
to: EmailAddress[];
|
||||
cc?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
date: Date | string;
|
||||
content?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
hasAttachments?: boolean;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
path?: string;
|
||||
content?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface EmailContentProps {
|
||||
email: Email;
|
||||
}
|
||||
|
||||
export default function EmailContent({ email }: EmailContentProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Render attachments if they exist
|
||||
const renderAttachments = () => {
|
||||
if (!email?.attachments || email.attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-gray-200 pt-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">Attachments</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{email.attachments.map((attachment, index) => (
|
||||
<div key={index} className="flex items-center space-x-2 p-2 border rounded hover:bg-gray-50">
|
||||
<Paperclip className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-600 truncate flex-1">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<Download className="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-pointer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive" className="m-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Format the date for display
|
||||
const formatDate = (dateObj: Date) => {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const date = new Date(dateObj);
|
||||
const formattedTime = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
if (date >= today) {
|
||||
return formattedTime;
|
||||
} else if (date >= yesterday) {
|
||||
return `Yesterday, ${formattedTime}`;
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="email-content-display p-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Avatar className="h-10 w-10 bg-gray-100">
|
||||
<AvatarFallback className="text-gray-600 font-medium">
|
||||
{email.from?.[0]?.name?.[0] || email.from?.[0]?.address?.[0] || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{email.from?.[0]?.name || email.from?.[0]?.address || 'Unknown'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
to {email.to?.[0]?.address || 'you'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto text-sm text-gray-500">
|
||||
{formatDate(new Date(email.date))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{email.content ? (
|
||||
<div className="email-content-display">
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(email.content) }} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">No content available</p>
|
||||
)}
|
||||
|
||||
{renderAttachments()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
components/email/EmailContentDisplay.tsx
Normal file
121
components/email/EmailContentDisplay.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { EmailContent } from '@/types/email';
|
||||
import { formatEmailContent } from '@/lib/utils/email-content';
|
||||
|
||||
interface EmailContentDisplayProps {
|
||||
content: EmailContent | string | null;
|
||||
className?: string;
|
||||
showQuotedText?: boolean;
|
||||
type?: 'html' | 'text' | 'auto';
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified component for displaying email content in a consistent way
|
||||
* This handles both HTML and plain text content with proper styling and RTL support
|
||||
*/
|
||||
const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
|
||||
content,
|
||||
className = '',
|
||||
showQuotedText = true,
|
||||
type = 'auto',
|
||||
debug = false
|
||||
}) => {
|
||||
// Process content if provided
|
||||
const processedContent = useMemo(() => {
|
||||
if (!content) {
|
||||
console.log('EmailContentDisplay: No content provided');
|
||||
return { __html: '<div class="email-content-empty">No content available</div>' };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('EmailContentDisplay processing:', {
|
||||
contentType: typeof content,
|
||||
isNull: content === null,
|
||||
isString: typeof content === 'string',
|
||||
isObject: typeof content === 'object',
|
||||
length: typeof content === 'string' ? content.length : null
|
||||
});
|
||||
|
||||
let formattedContent: string;
|
||||
|
||||
// If it's a string, we need to determine if it's HTML or plain text
|
||||
if (typeof content === 'string') {
|
||||
formattedContent = formatEmailContent({ content });
|
||||
}
|
||||
// If it's an EmailContent object
|
||||
else {
|
||||
formattedContent = formatEmailContent({ content });
|
||||
}
|
||||
|
||||
console.log('EmailContentDisplay processed result length:', formattedContent.length);
|
||||
return { __html: formattedContent };
|
||||
} catch (error) {
|
||||
console.error('Error processing email content:', error);
|
||||
return { __html: '<div class="email-content-error">Error displaying email content</div>' };
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// Handle quoted text display
|
||||
const displayHTML = useMemo(() => {
|
||||
if (!showQuotedText) {
|
||||
// Hide quoted text (usually in blockquotes)
|
||||
// This is simplified - a more robust approach would parse and handle
|
||||
// quoted sections more intelligently
|
||||
const htmlWithoutQuotes = processedContent.__html.replace(/<blockquote[^>]*>[\s\S]*?<\/blockquote>/gi,
|
||||
'<div class="text-gray-400">[Quoted text hidden]</div>');
|
||||
return { __html: htmlWithoutQuotes };
|
||||
}
|
||||
return processedContent;
|
||||
}, [processedContent, showQuotedText]);
|
||||
|
||||
return (
|
||||
<div className={`email-content-display ${className}`}>
|
||||
<div
|
||||
className="email-content-inner"
|
||||
dangerouslySetInnerHTML={displayHTML}
|
||||
/>
|
||||
|
||||
{/* Debug output if enabled */}
|
||||
{debug && (
|
||||
<div className="mt-4 p-2 text-xs bg-gray-100 border rounded">
|
||||
<p><strong>Content Type:</strong> {typeof content === 'string' ? 'Text' : 'HTML'}</p>
|
||||
<p><strong>HTML Length:</strong> {typeof content === 'string' ? content.length : content?.html?.length || 0}</p>
|
||||
<p><strong>Text Length:</strong> {typeof content === 'string' ? content.length : content?.text?.length || 0}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.email-content-display {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.email-content-inner img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.email-content-inner blockquote {
|
||||
margin: 10px 0;
|
||||
padding-left: 15px;
|
||||
border-left: 2px solid #ddd;
|
||||
color: #666;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* RTL blockquote styling will be handled by the direction attribute now */
|
||||
[dir="rtl"] blockquote {
|
||||
padding-left: 0;
|
||||
padding-right: 15px;
|
||||
border-left: none;
|
||||
border-right: 2px solid #ddd;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailContentDisplay;
|
||||
233
components/email/EmailDetailView.tsx
Normal file
233
components/email/EmailDetailView.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ChevronLeft, Reply, ReplyAll, Forward, Star, MoreHorizontal
|
||||
} from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Email } from '@/hooks/use-courrier';
|
||||
|
||||
interface EmailDetailViewProps {
|
||||
email: Email & {
|
||||
html?: string;
|
||||
text?: string;
|
||||
starred?: boolean; // Add starred property to interface
|
||||
};
|
||||
onBack: () => void;
|
||||
onReply: () => void;
|
||||
onReplyAll: () => void;
|
||||
onForward: () => void;
|
||||
onToggleStar: () => void;
|
||||
}
|
||||
|
||||
export default function EmailDetailView({
|
||||
email,
|
||||
onBack,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onToggleStar
|
||||
}: EmailDetailViewProps) {
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string | Date) => {
|
||||
// Convert to Date object if string
|
||||
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
|
||||
const now = new Date();
|
||||
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
// Render email content based on the email body
|
||||
const renderEmailContent = () => {
|
||||
try {
|
||||
console.log('EmailDetailView renderEmailContent', {
|
||||
hasContent: !!email.content,
|
||||
contentType: typeof email.content,
|
||||
hasHtml: !!email.html,
|
||||
hasText: !!email.text
|
||||
});
|
||||
|
||||
// Determine what content to use and how to handle it
|
||||
let contentToUse = '';
|
||||
|
||||
if (email.content) {
|
||||
// If content is a string, use it directly
|
||||
if (typeof email.content === 'string') {
|
||||
contentToUse = email.content;
|
||||
}
|
||||
// If content is an object with html/text properties
|
||||
else if (typeof email.content === 'object') {
|
||||
contentToUse = email.content.html || email.content.text || '';
|
||||
}
|
||||
}
|
||||
// Fall back to html or text properties if content is not available
|
||||
else if (email.html) {
|
||||
contentToUse = email.html;
|
||||
}
|
||||
else if (email.text) {
|
||||
// Convert plain text to HTML with line breaks
|
||||
contentToUse = email.text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// Return content or fallback message
|
||||
return contentToUse ?
|
||||
<div dangerouslySetInnerHTML={{ __html: contentToUse }} /> :
|
||||
<div className="text-gray-500">No content available</div>;
|
||||
} catch (e) {
|
||||
console.error('Error rendering email:', e);
|
||||
return <div className="text-gray-500">Failed to render email content</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Email actions header */}
|
||||
<div className="flex-none px-4 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="md:hidden flex-shrink-0"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="min-w-0 max-w-[500px]">
|
||||
<h2 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{email.subject}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<div className="flex items-center border-l border-gray-200 pl-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-400 hover:text-gray-900 h-9 w-9"
|
||||
onClick={onReply}
|
||||
>
|
||||
<Reply className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-400 hover:text-gray-900 h-9 w-9"
|
||||
onClick={onReplyAll}
|
||||
>
|
||||
<ReplyAll className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-400 hover:text-gray-900 h-9 w-9"
|
||||
onClick={onForward}
|
||||
>
|
||||
<Forward className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-400 hover:text-gray-900 h-9 w-9"
|
||||
onClick={onToggleStar}
|
||||
>
|
||||
<Star className={`h-4 w-4 ${email.starred ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area - enhanced for better scrolling */}
|
||||
<ScrollArea className="flex-1 overflow-auto">
|
||||
<div className="p-6">
|
||||
{/* Email header info */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>
|
||||
{(email.from?.[0]?.name || '').charAt(0) || (email.from?.[0]?.address || '').charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">
|
||||
{email.from?.[0]?.name || ''} <span className="text-gray-500"><{email.from?.[0]?.address || ''}></span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
to {email.to?.[0]?.address || ''}
|
||||
</p>
|
||||
{email.cc && email.cc.length > 0 && (
|
||||
<p className="text-sm text-gray-500">
|
||||
cc {email.cc.map(c => c.address).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 whitespace-nowrap">
|
||||
{formatDate(email.date)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email content with improved scrolling */}
|
||||
<div className="prose prose-sm max-w-none email-content-wrapper">
|
||||
{renderEmailContent()}
|
||||
</div>
|
||||
|
||||
{/* Attachments (if any) */}
|
||||
{email.hasAttachments && email.attachments && email.attachments.length > 0 && (
|
||||
<div className="mt-6 border-t border-gray-100 pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Attachments</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{email.attachments.map((attachment, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 p-2 border border-gray-200 rounded-md"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 truncate">{attachment.filename}</p>
|
||||
<p className="text-xs text-gray-500">{(attachment.size / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add CSS for better email content display */}
|
||||
<style jsx global>{`
|
||||
.email-content-wrapper {
|
||||
width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.email-content-wrapper img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.email-content-wrapper table {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.email-content-wrapper {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
components/email/EmailDialogs.tsx
Normal file
74
components/email/EmailDialogs.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
show: boolean;
|
||||
selectedCount: number;
|
||||
onConfirm: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
show,
|
||||
selectedCount,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: DeleteConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={show} onOpenChange={(open) => !open && onCancel()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {selectedCount} email{selectedCount !== 1 ? 's' : ''}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will move the selected email{selectedCount !== 1 ? 's' : ''} to the trash folder.
|
||||
You can restore them later from the trash folder if needed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginNeededAlertProps {
|
||||
show: boolean;
|
||||
onLogin: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LoginNeededAlert({
|
||||
show,
|
||||
onLogin,
|
||||
onClose
|
||||
}: LoginNeededAlertProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Alert className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Please log in to your email account</AlertTitle>
|
||||
<AlertDescription>
|
||||
You need to connect your email account before you can access your emails.
|
||||
</AlertDescription>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" onClick={onLogin}>Go to Login</Button>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>Dismiss</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
119
components/email/EmailHeader.tsx
Normal file
119
components/email/EmailHeader.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Search, X, Settings, Mail } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface EmailHeaderProps {
|
||||
onSearch: (query: string) => void;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
export default function EmailHeader({
|
||||
onSearch,
|
||||
onSettingsClick,
|
||||
}: EmailHeaderProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchQuery);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
onSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b bg-white/95 backdrop-blur-sm flex flex-col">
|
||||
{/* Courrier Title with improved styling */}
|
||||
<div className="p-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-6 w-6 text-blue-600" />
|
||||
<span className="text-xl font-semibold text-gray-900">COURRIER</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 flex items-center">
|
||||
<div className="flex-1">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search emails..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 pr-8 h-9 bg-gray-50 border-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-gray-600 hover:text-gray-900"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Search</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<DropdownMenu>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-600 hover:text-gray-900">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onSettingsClick}>
|
||||
Email settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onSettingsClick}>
|
||||
Configure IMAP
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user