Compare commits
707
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd72dec98f | ||
|
|
1331a3767c | ||
|
|
cb200330e7 | ||
|
|
178922323d | ||
|
|
91cf125a5d | ||
|
|
f4b7ef8117 | ||
|
|
d175fc2983 | ||
|
|
2f8bbdc636 | ||
|
|
b1573aada1 | ||
|
|
1cd6cde77c | ||
|
|
802a30508a | ||
|
|
70c1ddd48c | ||
|
|
2dc8537780 | ||
|
|
128d7d0401 | ||
|
|
d3d79be64c | ||
|
|
43475945bf | ||
|
|
09302684da | ||
|
|
e68fcb4aec | ||
|
|
ded13f02cc | ||
|
|
cfdee5e5c1 | ||
|
|
e7648eb1ac | ||
|
|
e7be3d1e0c | ||
|
|
9639a6bb75 | ||
|
|
904a62ce79 | ||
|
|
2903e56cf6 | ||
|
|
b5e0189938 | ||
|
|
a44bd7c3e6 | ||
|
|
0885d3c13e | ||
|
|
ef8eb1d73b | ||
|
|
9f67bc078a | ||
|
|
da411af6d3 | ||
|
|
265908b05b | ||
|
|
2a769c2b0a | ||
|
|
28054c81ea | ||
|
|
e7264f521c | ||
|
|
1b0ca8967e | ||
|
|
3e336d459c | ||
|
|
94f55afd1f | ||
|
|
7b058ed0ac | ||
|
|
853b0eb855 | ||
|
|
41a458d872 | ||
|
|
d1c5dba7d7 | ||
|
|
8c50abe221 | ||
|
|
07367a8a5d | ||
|
|
1a50788c91 | ||
|
|
0e06bfe273 | ||
|
|
f68e41d81a | ||
|
|
8b164c556e | ||
|
|
2e1f53c899 | ||
|
|
a6d2efaf74 | ||
|
|
01cd9644ed | ||
|
|
1521826d37 | ||
|
|
0d218d0d2a | ||
|
|
9777dd655c | ||
|
|
f970fd1822 | ||
|
|
5e096240b3 | ||
|
|
bc97a1ac10 | ||
|
|
4594fb2572 | ||
|
|
5319562c94 | ||
|
|
599fa66822 | ||
|
|
8041700668 | ||
|
|
bade68a8b8 | ||
|
|
4be7176802 | ||
|
|
8813533958 | ||
|
|
affa239d75 | ||
|
|
5d292fa43f | ||
|
|
878df6bb49 | ||
|
|
607a9584fd | ||
|
|
b8e2bfd793 | ||
|
|
4ad6d37877 | ||
|
|
d7c29b7bec | ||
|
|
b86bc541ab | ||
|
|
8c74e01a40 | ||
|
|
231a9017d2 | ||
|
|
9a5bb78b18 | ||
|
|
eecf16daa2 | ||
|
|
210150a02e | ||
|
|
e50691d6c4 | ||
|
|
089963b1b3 | ||
|
|
4af952613a | ||
|
|
0d9fa0285f | ||
|
|
683fe75864 | ||
|
|
e9c9be84ad | ||
|
|
7822a363dd | ||
|
|
1e535e96a2 | ||
|
|
32135ddb95 | ||
|
|
841513e510 | ||
|
|
5964b2e456 | ||
|
|
6a8ad525f1 | ||
|
|
31d17098d6 | ||
|
|
3f97e6ed8d | ||
|
|
2dea33e698 | ||
|
|
123764f8b8 | ||
|
|
ec0f355c13 | ||
|
|
c555973b6b | ||
|
|
a8db02e881 | ||
|
|
7dc5984359 | ||
|
|
1c3003421e | ||
|
|
f3d9115ecd | ||
|
|
4400a7abba | ||
|
|
45a4db1c22 | ||
|
|
65eef4b2b8 | ||
|
|
25de7d996c | ||
|
|
c406fbb73e | ||
|
|
31024396e3 | ||
|
|
f0967f90eb | ||
|
|
a4bb8e0c28 | ||
|
|
7188abc9bc | ||
|
|
4a91cd0c44 | ||
|
|
6abf8a5dd8 | ||
|
|
3667c842c6 | ||
|
|
b64721b43c | ||
|
|
6b5ca2cb89 | ||
|
|
0f6e4f995f | ||
|
|
0b6fdcabfb | ||
|
|
fc49fe0687 | ||
|
|
419382d25d | ||
|
|
8935b81f12 | ||
|
|
ec581ce53e | ||
|
|
81d8465a79 | ||
|
|
0f3b506604 | ||
|
|
1b84547211 | ||
|
|
dafc8ace3c | ||
|
|
2c419cc4fe | ||
|
|
90acf181f3 | ||
|
|
54af07f2af | ||
|
|
68f1fabc4b | ||
|
|
27451807db | ||
|
|
55099bdcbb | ||
|
|
71eb720065 | ||
|
|
8abb0c8717 | ||
|
|
3043639d2d | ||
|
|
e9b3eacbb7 | ||
|
|
f37e55e285 | ||
|
|
c0af2dbdd1 | ||
|
|
ae517732f7 | ||
|
|
3e1de10213 | ||
|
|
511740bb6d | ||
|
|
4a24d2a11d | ||
|
|
aadf56c27b | ||
|
|
9f8588eadc | ||
|
|
d657aec391 | ||
|
|
e683c90404 | ||
|
|
cfb4a23c9d | ||
|
|
fe1d4861bb | ||
|
|
5aa9b1d5f9 | ||
|
|
0913dbd3e4 | ||
|
|
29197ea355 | ||
|
|
4788e8a91a | ||
|
|
b80678b00f | ||
|
|
4f7c9c332b | ||
|
|
da103ff06f | ||
|
|
2111c77870 | ||
|
|
e5083ec1df | ||
|
|
df8d04e233 | ||
|
|
9a11a18a44 | ||
|
|
ce9f7af330 | ||
|
|
081e8a0310 | ||
|
|
6c3529b368 | ||
|
|
077a4f03a7 | ||
|
|
b04dfaf252 | ||
|
|
9c7452e7fd | ||
|
|
ec5593f567 | ||
|
|
c30c38a7af | ||
|
|
7494fc1776 | ||
|
|
16fe92d2e9 | ||
|
|
3544e6a9e7 | ||
|
|
dd1f3e11e6 | ||
|
|
6503482b55 | ||
|
|
cab57f6cd7 | ||
|
|
27f4fbdce4 | ||
|
|
39a228b20e | ||
|
|
4b069808d6 | ||
|
|
468851ff25 | ||
|
|
b7374570c8 | ||
|
|
3c9fa5dc25 | ||
|
|
3ade1c6473 | ||
|
|
1810a474a2 | ||
|
|
40982bc37b | ||
|
|
76f6149841 | ||
|
|
7d6a3c8c76 | ||
|
|
361ad49f5f | ||
|
|
f9aa5cbaee | ||
|
|
3f36045990 | ||
|
|
9a44babcf1 | ||
|
|
f032758303 | ||
|
|
92c7f74420 | ||
|
|
c2e4518cfa | ||
|
|
00f33afdf9 | ||
|
|
e566cfe687 | ||
|
|
6b7c849332 | ||
|
|
30c4afb977 | ||
|
|
1f60671886 | ||
|
|
794001fdbd | ||
|
|
9ad2facad3 | ||
|
|
89d8282846 | ||
|
|
c3960a99be | ||
|
|
e73ffa7449 | ||
|
|
37bd490072 | ||
|
|
24c53e5ce7 | ||
|
|
aa7f886795 | ||
|
|
00dec8c5a0 | ||
|
|
578e60c0bc | ||
|
|
8b21851353 | ||
|
|
15006086d2 | ||
|
|
bc3b923945 | ||
|
|
76ba9e5f85 | ||
|
|
44eb5fced2 | ||
|
|
172d8267ef | ||
|
|
f162f1e3d4 | ||
|
|
6fa0029d0b | ||
|
|
028e78a0c9 | ||
|
|
440a4e919a | ||
|
|
b8f39198e1 | ||
|
|
966bbe3957 | ||
|
|
522bf6a019 | ||
|
|
1689315c3a | ||
|
|
d4f7ae522e | ||
|
|
f05f70a9e5 | ||
|
|
850ee73048 | ||
|
|
5842f3f914 | ||
|
|
f303478850 | ||
|
|
8bdadc7ba3 | ||
|
|
6b57118add | ||
|
|
ffb645671c | ||
|
|
e63ce25f5f | ||
|
|
d31b30ba4a | ||
|
|
31eff96614 | ||
|
|
2ea8054240 | ||
|
|
5a2e141ed6 | ||
|
|
fa343e0768 | ||
|
|
8969338b2a | ||
|
|
4c720d6855 | ||
|
|
ad175d20e3 | ||
|
|
fb2f0c9158 | ||
|
|
7daa46e73e | ||
|
|
195185dc52 | ||
|
|
8a9dce1a99 | ||
|
|
c690e8eb76 | ||
|
|
9d867cbff6 | ||
|
|
f22699fe20 | ||
|
|
a7db3883aa | ||
|
|
7fcefa53c9 | ||
|
|
bdb76c3d90 | ||
|
|
168b36d419 | ||
|
|
a4f57e7a5c | ||
|
|
6678501501 | ||
|
|
fa0045e01b | ||
|
|
1b816d3185 | ||
|
|
24949e183f | ||
|
|
9207ec563c | ||
|
|
44e0e17203 | ||
|
|
b9c9901643 | ||
|
|
a201c1617b | ||
|
|
d24f402b0c | ||
|
|
5cfc905f10 | ||
|
|
4531cfe47c | ||
|
|
927a3b8b11 | ||
|
|
4ad8396adc | ||
|
|
6aaeb34272 | ||
|
|
ab57966a94 | ||
|
|
18d9b9adf6 | ||
|
|
ed311d79e3 | ||
|
|
457400ceee | ||
|
|
88d87be685 | ||
|
|
5ddb2acfc7 | ||
|
|
5f150f039d | ||
|
|
4f54f768e8 | ||
|
|
c24762c7a3 | ||
|
|
3e85e07363 | ||
|
|
facef97fcc | ||
|
|
89d580b90d | ||
|
|
734155c939 | ||
|
|
790d7084af | ||
|
|
60efb047d8 | ||
|
|
f9052eb23f | ||
|
|
9c5daa3918 | ||
|
|
23870801fc | ||
|
|
ae793551ba | ||
|
|
f19aaf6207 | ||
|
|
d0cc439fd1 | ||
|
|
f42f57b8d0 | ||
|
|
0d4b588fd0 | ||
|
|
6a725dde58 | ||
|
|
6499da6281 | ||
|
|
b2379fb03f | ||
|
|
b29e71124d | ||
|
|
63593e2146 | ||
|
|
10cbe7a637 | ||
|
|
79418f013a | ||
|
|
4024732696 | ||
|
|
ffad3ea78b | ||
|
|
a9b9aeb44d | ||
|
|
3d76fe6d75 | ||
|
|
58cafe28ad | ||
|
|
60f9a3dc4d | ||
|
|
11d9db5580 | ||
|
|
05d3f0b469 | ||
|
|
c3f60448ad | ||
|
|
523711cca3 | ||
|
|
bcb487810a | ||
|
|
7e1d644f7d | ||
|
|
4aac65d7d2 | ||
|
|
14ecae61dc | ||
|
|
c53ff5a30a | ||
|
|
5aad97d64e | ||
|
|
081c865018 | ||
|
|
050f38b1fa | ||
|
|
d657bdfa75 | ||
|
|
bbf724d9e1 | ||
|
|
9d8c6044e3 | ||
|
|
2d17ca71e3 | ||
|
|
d77dd1e3e1 | ||
|
|
9d97b74684 | ||
|
|
c4673acb65 | ||
|
|
1bdc51dcc7 | ||
|
|
6ee0f6a40a | ||
|
|
9ee25c930e | ||
|
|
1b2c70a90b | ||
|
|
cdea876992 | ||
|
|
16557830ae | ||
|
|
8836f9cdca | ||
|
|
26bfe9cb6c | ||
|
|
05eaaad61f | ||
|
|
2e0852b272 | ||
|
|
6173a9ad13 | ||
|
|
e3560d9cb4 | ||
|
|
52326326e2 | ||
|
|
2734fa08b7 | ||
|
|
67a0d622bc | ||
|
|
58968a1cbf | ||
|
|
01c8afbfe8 | ||
|
|
be2e0f2b68 | ||
|
|
aa40c8be26 | ||
|
|
b3d4c9241c | ||
|
|
34dd5122b3 | ||
|
|
dab3606b04 | ||
|
|
0f7638055c | ||
|
|
66fe7fd359 | ||
|
|
1b2ee7da3a | ||
|
|
f6bec519f4 | ||
|
|
7102add194 | ||
|
|
a3d894730b | ||
|
|
79b99ed4f8 | ||
|
|
68214c3e91 | ||
|
|
5da0e2bdf1 | ||
|
|
92ada0460a | ||
|
|
1cce5c3c8a | ||
|
|
40fd7799e9 | ||
|
|
aaa283357e | ||
|
|
066ab1bc32 | ||
|
|
92fb9bf132 | ||
|
|
4c804d5d2b | ||
|
|
63b464641b | ||
|
|
67210c9924 | ||
|
|
8937777bcd | ||
|
|
789d211a71 | ||
|
|
c512352f77 | ||
|
|
c859230862 | ||
|
|
0fb8b81acd | ||
|
|
1eebec292a | ||
|
|
2d983c9853 | ||
|
|
a453f7fa67 | ||
|
|
4c853f176b | ||
|
|
5583d95ecc | ||
|
|
9d1302ef0a | ||
|
|
75b5d31414 | ||
|
|
1e90885f3f | ||
|
|
02577c7502 | ||
|
|
375c0a7339 | ||
|
|
99d5276cfa | ||
|
|
60b2cf5911 | ||
|
|
8d7cd26fa9 | ||
|
|
1ba8e2fd47 | ||
|
|
c7c22bd210 | ||
|
|
6e0c79ca2c | ||
|
|
9140110435 | ||
|
|
e49f7c1a75 | ||
|
|
098127148e | ||
|
|
308adf0101 | ||
|
|
574bd7ecef | ||
|
|
d6b0714b4e | ||
|
|
af8ea8349e | ||
|
|
8eeabfc995 | ||
|
|
ddb636ed73 | ||
|
|
92ff5fe449 | ||
|
|
edcd5aa2a7 | ||
|
|
b5d471e9c8 | ||
|
|
f084b484b7 | ||
|
|
b868ad591d | ||
|
|
42734f16c3 | ||
|
|
b70c727bae | ||
|
|
9b0598b051 | ||
|
|
66cdfe64ce | ||
|
|
f6536573ae | ||
|
|
415c961937 | ||
|
|
ba6b25f3f1 | ||
|
|
a9002763e2 | ||
|
|
b75d064f20 | ||
|
|
a46fb9c8af | ||
|
|
6696636df8 | ||
|
|
37bc88dbad | ||
|
|
7a191cf78b | ||
|
|
733e99f094 | ||
|
|
9b25b6d03e | ||
|
|
05f848ee23 | ||
|
|
4da1b7d8fc | ||
|
|
4ff2bff974 | ||
|
|
47918aab1b | ||
|
|
e48672cf77 | ||
|
|
763abf43b9 | ||
|
|
649261c386 | ||
|
|
e02371d2bf | ||
|
|
4af20b8dc0 | ||
|
|
b5172802bb | ||
|
|
f64306be5e | ||
|
|
05f6d61cea | ||
|
|
0174051f3e | ||
|
|
136686f230 | ||
|
|
54981950b0 | ||
|
|
29a222eef4 | ||
|
|
76b21147e4 | ||
|
|
78bcf8db1b | ||
|
|
39c43e3b3d | ||
|
|
55ee99e811 | ||
|
|
31e7ecceda | ||
|
|
d666bddbc4 | ||
|
|
1cac71fae7 | ||
|
|
79ff95565d | ||
|
|
808328f5a4 | ||
|
|
ddbeadd3c8 | ||
|
|
501d69500d | ||
|
|
e07365b066 | ||
|
|
2baf5e84a5 | ||
|
|
024487bf10 | ||
|
|
97bc26a332 | ||
|
|
55c8d430ca | ||
|
|
ef45140d32 | ||
|
|
0effb97691 | ||
|
|
f7ee204262 | ||
|
|
0c1f182b6b | ||
|
|
13010c158d | ||
|
|
de26e6da2e | ||
|
|
ddb3422852 | ||
|
|
d9a2529261 | ||
|
|
e70224317d | ||
|
|
a9ecf164ab | ||
|
|
0db3cbc959 | ||
|
|
387273288c | ||
|
|
8eff9fdfab | ||
|
|
d915e5fb64 | ||
|
|
5530cfe7fe | ||
|
|
31eee4bab9 | ||
|
|
f04b97d52a | ||
|
|
df272e38ef | ||
|
|
a835af2d71 | ||
|
|
30284859c7 | ||
|
|
64c3d7e384 | ||
|
|
e6d09546b1 | ||
|
|
83a0a1e235 | ||
|
|
7c3c3b5f7b | ||
|
|
8b1b3ad57b | ||
|
|
afefbb8d46 | ||
|
|
4c2d185be4 | ||
|
|
c73940e22a | ||
|
|
09eda86d3f | ||
|
|
0d28d811a8 | ||
|
|
45a485a1fa | ||
|
|
bc202498d5 | ||
|
|
721556e777 | ||
|
|
8c9cf3a66b | ||
|
|
b88026de82 | ||
|
|
6ed8ae5812 | ||
|
|
089583a9ef | ||
|
|
2d834213ee | ||
|
|
439a4dbe8a | ||
|
|
8350bad2a6 | ||
|
|
2d56cc9be9 | ||
|
|
2547c10060 | ||
|
|
01779fa59e | ||
|
|
fc38427ed0 | ||
|
|
3cbfb70860 | ||
|
|
c32b740dac | ||
|
|
a02091a7ad | ||
|
|
bc311adf6a | ||
|
|
8aca1623f4 | ||
|
|
a8be40579e | ||
|
|
705b942800 | ||
|
|
d68b81e6b8 | ||
|
|
616e4d018d | ||
|
|
68e141b787 | ||
|
|
1e6f5e2c8c | ||
|
|
9495b34430 | ||
|
|
65fc489b9c | ||
|
|
bd686c092c | ||
|
|
6cff98ddb8 | ||
|
|
dcc35335f5 | ||
|
|
8a54ae2456 | ||
|
|
e26654a005 | ||
|
|
c1c06c68bb | ||
|
|
74cf642182 | ||
|
|
c5b1731a63 | ||
|
|
40cf164df3 | ||
|
|
ff56245db8 | ||
|
|
9b4de4d152 | ||
|
|
0c9e60db8b | ||
|
|
def8ee89fa | ||
|
|
e7e07a38d7 | ||
|
|
a009e5ae32 | ||
|
|
b141240fa3 | ||
|
|
44896dee3e | ||
|
|
a5c5fa6669 | ||
|
|
95af61c4be | ||
|
|
34e495dde3 | ||
|
|
0b721661e9 | ||
|
|
9fa851a674 | ||
|
|
41f91244d9 | ||
|
|
77514bd054 | ||
|
|
4501b3894b | ||
|
|
2edf2fab89 | ||
|
|
d493bb17dc | ||
|
|
234129397d | ||
|
|
9b3a47f9be | ||
|
|
0fcc932e66 | ||
|
|
0fe8e81dc7 | ||
|
|
267f7257cf | ||
|
|
fb8c9db716 | ||
|
|
2793d4b4af | ||
|
|
96c2ee9e13 | ||
|
|
af115e3245 | ||
|
|
fc79bf4f9b | ||
|
|
b844b88733 | ||
|
|
bcdde9f454 | ||
|
|
bb72ac92ae | ||
|
|
6457b27125 | ||
|
|
ef562bcaad | ||
|
|
9fdbb62205 | ||
|
|
2edbf379e2 | ||
|
|
cdc521b693 | ||
|
|
e7249f8bd3 | ||
|
|
a57492d7c0 | ||
|
|
2b4ff2f3de | ||
|
|
5a3f9faafe | ||
|
|
a74cd32364 | ||
|
|
38c04099e2 | ||
|
|
ae14e09f66 | ||
|
|
ada356e440 | ||
|
|
a886edda6f | ||
|
|
ff97d8bc4c | ||
|
|
b1db100c3a | ||
|
|
19584cfef6 | ||
|
|
8a2ef5a6c5 | ||
|
|
477fc8c885 | ||
|
|
84abfe79dd | ||
|
|
8a92d53125 | ||
|
|
6fe4a98b02 | ||
|
|
3186198fad | ||
|
|
5c933a595f | ||
|
|
828ad4df72 | ||
|
|
f720cb3ef8 | ||
|
|
818a02428b | ||
|
|
6ed30059e5 | ||
|
|
a2cb2b2c86 | ||
|
|
1f7cd61fc7 | ||
|
|
070eeeecee | ||
|
|
de35d1d8e8 | ||
|
|
7c5785e9e8 | ||
|
|
54f4d37595 | ||
|
|
71cd826c6f | ||
|
|
e96b9a72e7 | ||
|
|
ce4ebb3dc2 | ||
|
|
6680b65863 | ||
|
|
df64959e60 | ||
|
|
8641275bb4 | ||
|
|
6e78f2a09e | ||
|
|
d429652f72 | ||
|
|
a834528bc1 | ||
|
|
7cf79d8775 | ||
|
|
dc3e5b7706 | ||
|
|
d86d109725 | ||
|
|
0f5d030d5f | ||
|
|
cde1d61d02 | ||
|
|
4b262a2746 | ||
|
|
119bfed1eb | ||
|
|
3cd123778e | ||
|
|
f2f3e1d85c | ||
|
|
f7a38a22ee | ||
|
|
e759056d5f | ||
|
|
1b35c9e3d6 | ||
|
|
89488e6fc9 | ||
|
|
7af16f2de0 | ||
|
|
ed4be96706 | ||
|
|
8044c60905 | ||
|
|
8d0c525e68 | ||
|
|
800499f54a | ||
|
|
e02477323f | ||
|
|
37b0bacccb | ||
|
|
716db8d003 | ||
|
|
73d6e581b7 | ||
|
|
f4d34477a6 | ||
|
|
26b38982ac | ||
|
|
9bffb72338 | ||
|
|
0965fbc7c1 | ||
|
|
d6cf195bfc | ||
|
|
0080d27b3e | ||
|
|
5643170220 | ||
|
|
e87e35cc48 | ||
|
|
b7fa25c314 | ||
|
|
c24fabe977 | ||
|
|
1f1db8fac8 | ||
|
|
cb74e3bf73 | ||
|
|
05f02402e3 | ||
|
|
5accf0a86c | ||
|
|
8c5e3ee240 | ||
|
|
caed067cda | ||
|
|
f149501dff | ||
|
|
a9a8710909 | ||
|
|
d1478fa46b | ||
|
|
d5404de224 | ||
|
|
838de8e5da | ||
|
|
86673a7b45 | ||
|
|
f7bbab5e4a | ||
|
|
f995558bf5 | ||
|
|
f1f31df0af | ||
|
|
c024d89477 | ||
|
|
1936a37f28 | ||
|
|
98cb3e158b | ||
|
|
85b5b3c4f1 | ||
|
|
6fc27804d6 | ||
|
|
4a90cf4fa0 | ||
|
|
528515611e | ||
|
|
6c4acdc655 | ||
|
|
202a75db0c | ||
|
|
d7b99ac95c | ||
|
|
596ebecfbb | ||
|
|
0ea79f29db | ||
|
|
4396bc9943 | ||
|
|
2fe1b01d4d | ||
|
|
d49dcb52e6 | ||
|
|
81c0a8a971 | ||
|
|
56f4195ab5 | ||
|
|
099434a125 | ||
|
|
0cd928d1fe | ||
|
|
04e8b023de | ||
|
|
ecfb197c82 | ||
|
|
9438cf75ab | ||
|
|
091c10d828 | ||
|
|
02808fa4c3 | ||
|
|
82a790de0f | ||
|
|
e85d0b946e | ||
|
|
02344d169c | ||
|
|
1267efa307 | ||
|
|
5a679275ca | ||
|
|
25f0088957 | ||
|
|
18485951c7 | ||
|
|
5e2329b55f | ||
|
|
46a80f3773 | ||
|
|
21e6fd9078 | ||
|
|
2a48740ebd | ||
|
|
8509550812 | ||
|
|
60a6ede063 | ||
|
|
eb6e73191f | ||
|
|
af64c96b9f | ||
|
|
b090bf6d52 | ||
|
|
ae29e64197 | ||
|
|
260ea7088d | ||
|
|
db8e827f68 | ||
|
|
6f0d47cbd9 | ||
|
|
2a73adf588 | ||
|
|
7fedcb8e58 | ||
|
|
4737f2928b | ||
|
|
d1d2a1dc53 | ||
|
|
3efb292c3b | ||
|
|
bb40326809 | ||
|
|
d8b0f013e8 | ||
|
|
f751274e41 | ||
|
|
2249741c61 | ||
|
|
ab72fc06ff | ||
|
|
68d4a9a641 | ||
|
|
5598c436f2 | ||
|
|
256ff7698d | ||
|
|
7848c90625 | ||
|
|
f996ab77b8 | ||
|
|
a88b4e4407 | ||
|
|
cc8195209b | ||
|
|
288205fbac | ||
|
|
15dbb3d349 | ||
|
|
5f539161d5 | ||
|
|
d3a5b4189e | ||
|
|
e5c1cd45c5 | ||
|
|
454d51e283 | ||
|
|
ffeb534740 | ||
|
|
61459c4923 | ||
|
|
d23e9a642c | ||
|
|
723aff38f2 | ||
|
|
a8a1ad1d54 | ||
|
|
cf5f271be2 | ||
|
|
d09022378b | ||
|
|
4576d37c7a | ||
|
|
40e7fa3776 | ||
|
|
c1956bfb2d | ||
|
|
c7485f532c | ||
|
|
fed508d5b7 | ||
|
|
8985865843 | ||
|
|
d41718768b | ||
|
|
481d566eb6 | ||
|
|
cf02f587de |
+1
-2
@@ -4,9 +4,8 @@ node_modules
|
||||
.gitignore
|
||||
.env*
|
||||
!.env.example
|
||||
.claude/
|
||||
!.env.dev.example
|
||||
scripts/
|
||||
TODO.md
|
||||
CLAUDE.md
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Bulwark Webmail - Development Configuration
|
||||
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
||||
# No external mail server required - great for UI development and testing.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.dev.example .env.local
|
||||
# npm run dev
|
||||
# Open http://localhost:3000 - log in with any username/password.
|
||||
|
||||
# =============================================================================
|
||||
# Mock JMAP Server
|
||||
# =============================================================================
|
||||
|
||||
# Enable the built-in mock JMAP server (served at /api/dev-jmap)
|
||||
DEV_MOCK_JMAP=true
|
||||
|
||||
# Point the app at its own mock endpoint.
|
||||
# IMPORTANT: This must match the origin the app runs on (default: port 3000).
|
||||
# Using a different port (e.g. 3001) will cause CORS errors.
|
||||
JMAP_SERVER_URL=/api/dev-jmap
|
||||
|
||||
# =============================================================================
|
||||
# App
|
||||
# =============================================================================
|
||||
|
||||
APP_NAME=Bulwark Webmail (Dev)
|
||||
|
||||
# =============================================================================
|
||||
# Session & Settings Sync (optional for dev)
|
||||
# =============================================================================
|
||||
|
||||
SESSION_SECRET=dev-secret-not-for-production
|
||||
SETTINGS_SYNC_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# Logging (verbose for development)
|
||||
# =============================================================================
|
||||
|
||||
LOG_FORMAT=text
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# =============================================================================
|
||||
# Login Page Customization (optional)
|
||||
# =============================================================================
|
||||
|
||||
# LOGIN_COMPANY_NAME=Dev Team
|
||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
# LOGIN_WEBSITE_URL=https://example.com
|
||||
+204
-24
@@ -1,42 +1,222 @@
|
||||
# JMAP Webmail Configuration
|
||||
# Copy this file to .env.local and fill in your values
|
||||
# Bulwark Webmail - Production Configuration
|
||||
# Copy this file to .env.local and fill in your values.
|
||||
# For development with the built-in mock server, see .env.dev.example instead.
|
||||
|
||||
# =============================================================================
|
||||
# Runtime Configuration (recommended for Docker)
|
||||
# These are read at request time, allowing post-build configuration
|
||||
# JMAP Server (required)
|
||||
# =============================================================================
|
||||
|
||||
# App name displayed in the UI
|
||||
APP_NAME=JMAP Webmail
|
||||
# App name displayed in the UI, browser tab title, and PWA manifest.
|
||||
APP_NAME=Bulwark Webmail
|
||||
|
||||
# JMAP server URL (required)
|
||||
# This is the URL of your JMAP-compatible mail server
|
||||
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||
JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
|
||||
# =============================================================================
|
||||
# Build-time Configuration (legacy, still supported as fallback)
|
||||
# These are baked into the bundle at build time
|
||||
# =============================================================================
|
||||
|
||||
# NEXT_PUBLIC_APP_NAME=JMAP Webmail
|
||||
# NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Allow users to specify a custom JMAP server URL on the login form.
|
||||
# When enabled, a "JMAP Server" field appears on the login page.
|
||||
# Users can connect to any JMAP-compatible server.
|
||||
# NOTE: External JMAP servers must include this domain in their CORS
|
||||
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||
|
||||
# =============================================================================
|
||||
# Logging Configuration
|
||||
# Stalwart Mail Server Integration
|
||||
# =============================================================================
|
||||
|
||||
# Enable Stalwart-specific features (password change, sieve filters, etc.)
|
||||
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
||||
# STALWART_FEATURES=true
|
||||
|
||||
# =============================================================================
|
||||
# OAuth / OpenID Connect (optional)
|
||||
# =============================================================================
|
||||
|
||||
# Set to "true" to use OAuth instead of basic JMAP authentication
|
||||
# OAUTH_ENABLED=true
|
||||
|
||||
# Set to "true" to only allow OAuth login (hides username/password form)
|
||||
# Requires OAUTH_ENABLED=true
|
||||
# OAUTH_ONLY=true
|
||||
|
||||
# OAuth client ID registered with your identity provider
|
||||
# OAUTH_CLIENT_ID=your-client-id
|
||||
|
||||
# OAuth client secret (server-side only, never exposed to the browser)
|
||||
# OAUTH_CLIENT_SECRET=your-client-secret
|
||||
# Alternatively, you can specify the path to a file containing the OAuth client secret.
|
||||
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
|
||||
|
||||
# OpenID Connect issuer URL for discovery
|
||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||
|
||||
# =============================================================================
|
||||
# Session & Security
|
||||
# =============================================================================
|
||||
|
||||
# Secret key for encrypting "Remember me" sessions and settings sync data.
|
||||
# Required for both "Remember me" and settings sync features.
|
||||
# Generate with: openssl rand -base64 32
|
||||
# SESSION_SECRET=your-secret-key-here
|
||||
# Alternatively, you can specify the path to a file containing the session secret.
|
||||
# SESSION_SECRET_FILE=/session-secret
|
||||
|
||||
# =============================================================================
|
||||
# Settings Sync
|
||||
# =============================================================================
|
||||
|
||||
# Enable server-side settings persistence (requires SESSION_SECRET).
|
||||
# When enabled, user settings are encrypted and stored on the server,
|
||||
# allowing them to sync across browsers and devices.
|
||||
# SETTINGS_SYNC_ENABLED=true
|
||||
|
||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||
# For Docker, the working directory is /app, so the default resolves to
|
||||
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
|
||||
# SETTINGS_DATA_DIR=./data/settings
|
||||
|
||||
# =============================================================================
|
||||
# Admin Dashboard Data
|
||||
# =============================================================================
|
||||
|
||||
# Directory for admin dashboard state: config overrides, admin password hash,
|
||||
# installed plugins/themes, and audit logs (default: ./data/admin).
|
||||
# For Docker, the default resolves to /app/data/admin - mount a persistent
|
||||
# volume there (see docker-compose.yml).
|
||||
# ADMIN_DATA_DIR=./data/admin
|
||||
|
||||
# =============================================================================
|
||||
# Anonymous Telemetry
|
||||
# =============================================================================
|
||||
|
||||
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
|
||||
# version, platform, bucketed account counts, and feature toggles only. See
|
||||
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
||||
#
|
||||
# Disable telemetry entirely (overrides the admin UI):
|
||||
# BULWARK_TELEMETRY=off
|
||||
|
||||
# Directory for telemetry state: instance id, consent, login HMACs
|
||||
# (default: ./data/telemetry). For Docker, the default resolves to
|
||||
# /app/data/telemetry - mount a persistent volume there (see docker-compose.yml)
|
||||
# so the instance id and consent choice survive upgrades.
|
||||
# TELEMETRY_DATA_DIR=./data/telemetry
|
||||
|
||||
# =============================================================================
|
||||
# Server Listen Address
|
||||
# =============================================================================
|
||||
|
||||
# Hostname the server binds to (default: 0.0.0.0)
|
||||
# Set to "::" for dual-stack
|
||||
# HOSTNAME=0.0.0.0
|
||||
|
||||
# Port the server listens on (default: 3000)
|
||||
# PORT=3000
|
||||
|
||||
# =============================================================================
|
||||
# Logging
|
||||
# =============================================================================
|
||||
|
||||
# Log format: "text" (colored, human-readable) or "json" (structured, for log aggregation)
|
||||
LOG_FORMAT=text
|
||||
# LOG_FORMAT=text
|
||||
|
||||
# Log level: "error", "warn", "info", or "debug"
|
||||
LOG_LEVEL=info
|
||||
# LOG_LEVEL=info
|
||||
|
||||
# =============================================================================
|
||||
# Docker Configuration
|
||||
# Branding (all optional)
|
||||
# =============================================================================
|
||||
# When running with Docker, set these in .env.local:
|
||||
# APP_NAME=My Webmail
|
||||
# JMAP_SERVER_URL=https://mail.example.com
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App identity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Short name for the app, used in contexts where space is limited
|
||||
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
|
||||
# APP_SHORT_NAME=Bulwark
|
||||
|
||||
# Description shown in the PWA manifest (displayed by the OS during install).
|
||||
# Defaults to a generic Bulwark description if not set.
|
||||
# APP_DESCRIPTION=Your personal webmail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icons & favicon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Custom favicon shown in the browser tab.
|
||||
# Supported formats: SVG (recommended), PNG, ICO.
|
||||
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||
# Defaults to the Bulwark favicon if not set.
|
||||
# FAVICON_URL=/branding/my-favicon.svg
|
||||
|
||||
# Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
|
||||
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
|
||||
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
|
||||
# PWA_ICON_URL=/branding/my-icon.svg
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PWA appearance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Color applied to the browser UI chrome when the app is installed as a PWA
|
||||
# (address bar, status bar on Android). Default: #ffffff
|
||||
# PWA_THEME_COLOR=#3b82f6
|
||||
|
||||
# Background color shown on the PWA splash screen while the app is loading.
|
||||
# Should match your app's main background color. Default: #ffffff
|
||||
# PWA_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Logos shown in the sidebar (main app, after login).
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 24×24px, max 128×128px.
|
||||
# Can be absolute URLs or paths relative to the public/ directory.
|
||||
# If not set, no logo is shown in the sidebar.
|
||||
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||
|
||||
# Logos shown on the login page.
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 32×32px, max 512×512px.
|
||||
# Can be absolute URLs or paths relative to the public/ directory.
|
||||
# Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
|
||||
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
||||
# Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
|
||||
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Company name shown above the version number on the login page.
|
||||
LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||
|
||||
# URL for the imprint / legal notice link on the login page.
|
||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
|
||||
# URL for the privacy policy link on the login page.
|
||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
|
||||
# URL for the company website link on the login page.
|
||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
# Extension Directory / Marketplace
|
||||
# =============================================================================
|
||||
|
||||
# URL of the BulwarkMail extension directory for the admin marketplace.
|
||||
# Defaults to https://extensions.bulwarkmail.org. Override only if you run
|
||||
# your own directory (e.g. http://localhost:3001 for local development).
|
||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
# Legacy Build-time Variables (still supported as fallback)
|
||||
# =============================================================================
|
||||
# These are baked into the bundle at build time. The runtime variables above
|
||||
# take priority when both are set.
|
||||
#
|
||||
# Then run:
|
||||
# docker compose up -d
|
||||
# NEXT_PUBLIC_APP_NAME=Bulwark Webmail
|
||||
# NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Bulwark Webmail – Funding configuration
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
github: [bulwarkmail]
|
||||
@@ -0,0 +1,120 @@
|
||||
name: Publish Docker Image on Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
@@ -2,7 +2,9 @@ name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- ".dockerignore"
|
||||
@@ -17,12 +19,34 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/docker-publish.yml"
|
||||
tags: ["v*.*.*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image_name: ${{ steps.set.outputs.image_name }}
|
||||
steps:
|
||||
- name: Set image name
|
||||
id: set
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -31,18 +55,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -54,22 +69,75 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
rootfr/jmap-webmail
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=
|
||||
images: ${{ needs.prepare.outputs.image_name }}
|
||||
|
||||
- name: Build and push
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, build]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ needs.prepare.outputs.image_name }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Publish Standalone Tarball on Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build standalone
|
||||
run: npm run build
|
||||
|
||||
- name: Package tarball
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
VERSION="${REF_NAME#v}"
|
||||
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
|
||||
|
||||
mkdir -p bulwark-standalone
|
||||
cp -r .next/standalone/. bulwark-standalone/
|
||||
cp -r .next/static bulwark-standalone/.next/static
|
||||
cp -r public bulwark-standalone/public
|
||||
|
||||
tar -czf "$TARBALL" bulwark-standalone/
|
||||
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload release asset
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
|
||||
|
||||
- name: Upload artifact (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: standalone-${{ matrix.arch }}
|
||||
path: ${{ env.TARBALL }}
|
||||
retention-days: 7
|
||||
+9
-2
@@ -33,6 +33,7 @@ yarn-error.log*
|
||||
# env files
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.dev.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -41,5 +42,11 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# claude code
|
||||
.claude/
|
||||
# settings sync data
|
||||
/data/
|
||||
|
||||
# Local private data
|
||||
/local-data/
|
||||
|
||||
# Sibling repos
|
||||
/repos/
|
||||
@@ -1,7 +0,0 @@
|
||||
# Check for AI attribution in commit message
|
||||
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
|
||||
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
|
||||
echo " This violates project policy in CLAUDE.md"
|
||||
echo " Remove 'Co-Authored-By: Claude' and similar references"
|
||||
exit 1
|
||||
fi
|
||||
+706
-3
@@ -1,12 +1,715 @@
|
||||
# Changelog
|
||||
|
||||
## 1.6.2 (2026-05-06)
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: Hot-reload and dev-folder loading for live plugin development
|
||||
- **Plugins**: On-demand `src/` bundling via esbuild
|
||||
- **Plugins**: New `http:fetch` permission and `httpOrigins` manifest field
|
||||
- **Plugins**: `onBeforeEmailSend` hook with `fromEmail` exposed on `OutgoingEmail`
|
||||
- **Plugins**: Project `EmailReadView` for the email-banner slot and expose auth results
|
||||
- **Plugins**: Ingest icon, banner, and screenshots from the source repo
|
||||
- **Plugins**: Restrict plugin and theme install/uninstall to the admin dashboard
|
||||
- **Mail**: Multi-server JMAP support
|
||||
- **Settings**: Fulltext search across the settings sidebar
|
||||
- **Settings**: Sub-result rows with highlight in settings search
|
||||
- **Settings**: Surface plugin settings as search sub-results
|
||||
- **Settings**: Remove experimental tags from themes, plugins, and sender favicons
|
||||
- **Viewer**: Redesigned external-mail banner above attachments
|
||||
- **Calendar**: Calendar invitation banner expands on row click
|
||||
- **Calendar**: Calendar invitation banner is now collapsible
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Admin**: Collapse admin panel into a single tabbed page
|
||||
- **Plugins**: Inline plugin configure panel to avoid dev-mode hang
|
||||
- **Plugins**: Resolve `PLUGIN_DEV_DIR` plugins in admin config route
|
||||
- **Plugins**: Add missing body type assertion in `createPluginAPI` fetch options
|
||||
- **Plugins**: Propagate `settingsSchema`
|
||||
- **Settings**: Highlight plugin and theme cards in search results
|
||||
- **Settings**: Open plugin card on first click of a setting sub-result
|
||||
- **Settings**: Drop ghost sub-results from account and language search
|
||||
- **Settings**: Improve search highlight styling
|
||||
- **Viewer**: Show notification banners above attachments
|
||||
- **Viewer**: Rework S/MIME banner to match calendar invitation
|
||||
- **Viewer**: Close PDF preview on Escape before email viewer
|
||||
- **Viewer**: Render PDF previews via `<object>` with `blob:` in object-src CSP (#253)
|
||||
- **Calendar**: Align invitation icon with sender avatar column
|
||||
- **Calendar**: Fix invitation picker clipping (#250)
|
||||
- **Auth**: Read `activeAccountId` from authStore in account selectors
|
||||
- **UI**: Adjust toast item border radius and progress bar styles
|
||||
- **UI**: Remove fly-in animation from context menu submenus
|
||||
- **i18n**: Add missing Czech flag icon
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 15 locales
|
||||
|
||||
## 1.6.1 (2026-05-04)
|
||||
|
||||
### Features
|
||||
|
||||
- **Updates**: Update-available detection with non-dismissible notice and dev-reload refresh
|
||||
- **Plugins**: New plugin hooks for compose, attachments, search, lifecycle, and routing
|
||||
- **Sharing**: Share indicators for calendars and contacts, updated JMAP capabilities (#244)
|
||||
- **Mail**: Auto-add recipients to trusted senders when replying
|
||||
- **Identity**: Sanitize identity display name to prevent invalid `From` headers
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mobile**: Synchronize mobile submenu view with browser history for better navigation
|
||||
- **Viewer**: Update email viewer styles to improve overflow handling
|
||||
- **Auth**: Ensure `cookieSlot` consistency during account updates in auth store
|
||||
- **Auth**: Thread per-account cookie slot through OAuth flows
|
||||
- **Calendar**: Square the colored left marker on calendar events
|
||||
- **About**: Show git commit in About instead of "unknown"
|
||||
|
||||
### i18n
|
||||
|
||||
- Update mailbox context menu translations across 12 locales
|
||||
|
||||
## 1.6.0 (2026-05-01)
|
||||
|
||||
### Features
|
||||
|
||||
- **Deployment**: Subpath deployment support via `NEXT_PUBLIC_BASE_PATH` environment variable
|
||||
- **Mail**: Image attachment thumbnails and preview chips
|
||||
- **Mobile**: Reworked mobile mail viewer toolbar
|
||||
- **Mobile**: Mobile-friendly settings panel
|
||||
- **Mobile**: Mobile-friendly admin panel
|
||||
- **Mail**: Redesigned expanded details panel
|
||||
- **Mailbox**: Show full path in mailbox context menu header with intelligent path shortening
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Viewer**: Respect per-email dark mode toggle when "always show in light mode" is on
|
||||
- **Navigation**: Scroll apps list in navigation rail to prevent overflow
|
||||
- **Context menu**: Clamp submenu inside viewport
|
||||
- **Context menu**: Prevent context menu from clipping below viewport
|
||||
- **Context menu**: Prevent jump and animation on open
|
||||
- **Mail**: Stop silently destroying emails when trash mailbox isn't found (#195)
|
||||
- **Mail**: Preserve list scroll position when tagging an email
|
||||
- **Mail**: Render below-header overflow popup outside clipped row
|
||||
- **Mail**: Collapse below-header attachments to single row with overflow pill
|
||||
- **Push**: Fix push preview JMAP query
|
||||
- **Tour**: Navigate tour to mailbox when starting from another page
|
||||
- **i18n**: Add `useTranslations` for "selected emails" and "cancel" on email list batch operations
|
||||
|
||||
### i18n
|
||||
|
||||
- Translate SPF/DKIM/DMARC tooltips
|
||||
- Add missing keys across 14 locales
|
||||
|
||||
## 1.5.4 (2026-05-01)
|
||||
|
||||
### Features
|
||||
|
||||
- **PWA**: Web push notifications for new inbox mail (#233), with click-through to open the message
|
||||
- **Composer**: Insert and edit tables in rich-text emails (#236)
|
||||
- **Mail**: Configurable sub-addressing delimiter character (#239)
|
||||
- **i18n**: Turkish localization
|
||||
- **i18n**: Missing keys filled in across 15 locales
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Set In-Reply-To and References headers on replies (#234)
|
||||
- **Mail**: Persist htmlBody in drafts to preserve rich formatting (#236)
|
||||
- **Auth**: Pin JMAP auth verification to the configured server URL (#237)
|
||||
- **Auth**: Evict unrecoverable basic-auth accounts on reload
|
||||
- **Notifications**: Scope new-mail notifications to genuine inbox deliveries
|
||||
- **Notifications**: Extend PushVerification timeout and clean up leftover subscriptions
|
||||
- **Viewer**: Smooth out body load to prevent flicker on first render
|
||||
- **Viewer**: Prevent iframe flash when loading images or trusting the sender
|
||||
- **Viewer**: Pad bare HTML emails like plain-text mails for consistent layout
|
||||
- **Viewer**: Light-mode override now only affects body content
|
||||
- **Viewer**: Detect `<style>` tag when applying padding
|
||||
- **Viewer**: Drop iframe border-radius
|
||||
- **Calendar**: Localize event start date in detail popover and event modal
|
||||
- **Dev**: Include http protocol in connect-src for development mode CSP
|
||||
|
||||
## 1.5.3 (2026-04-28)
|
||||
|
||||
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
||||
|
||||
### Features
|
||||
|
||||
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only - disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
|
||||
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
|
||||
- **Plugins**: Theme API v2 with token compiler and skin slot
|
||||
- **Plugins**: Extension preview page and detailed extension info API
|
||||
- **Calendar**: Right-click context menu on empty calendar space
|
||||
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
|
||||
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
|
||||
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
|
||||
|
||||
## 1.5.2 (2026-04-27)
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission - plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
|
||||
- **Plugins**: Manifests can declare `frameOrigins` - a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
|
||||
- **Calendar/Contacts**: JMAP sharing for calendars and address books
|
||||
- **i18n**: Czech language support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Security**: Validate URLs before outbound fetch
|
||||
- **Calendar**: Prevent drag creation on touch events in the time grid
|
||||
- **Contacts**: Emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import (#224, #187)
|
||||
- **Mail**: Hide preview line in compact density to match settings preview (#223)
|
||||
- **Proxy**: Inline matcher for Next.js proxy and drop unnecessary Node.js runtime config
|
||||
- **i18n**: Portuguese fixes for "ficheiro" and "contactos" variants
|
||||
|
||||
## 1.5.1 (2026-04-25)
|
||||
|
||||
### Features
|
||||
|
||||
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
|
||||
- **Mail**: Right-click context menu on the folders sidebar
|
||||
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
|
||||
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
|
||||
- **Junk**: Setting to show avatars in the Junk folder (off by default)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
|
||||
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
|
||||
- **Folders**: Stop flicker on background folder refresh
|
||||
- **Email**: Preserve search/filter on batch move and archive
|
||||
- **Email**: Preserve search/filter when moving emails via drag-drop
|
||||
- **i18n**: Improve Korean flag
|
||||
|
||||
## 1.5.0 (2026-04-22)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
|
||||
|
||||
### Features
|
||||
|
||||
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
|
||||
- **Admin**: Add API Keys management and IP allowlist for App Passwords
|
||||
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
|
||||
- **Contacts**: Add contact activity component showing recent emails and upcoming events
|
||||
- **Contacts**: Add right-click context menu
|
||||
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
|
||||
- **Calendar**: Support resizing events from the top edge
|
||||
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
|
||||
- **Calendar**: Optimize layout of overlapping events
|
||||
- **Calendar**: Add collapsible details to calendar invitation banner
|
||||
- **Email**: Implement batch archiving and bulk moving of emails
|
||||
- **Email**: Show full folder path in move/drop toast
|
||||
- **Settings**: Reorganize settings into 6 groups with clearer tabs
|
||||
- **Navigation**: Add account-addition button to the navigation rail
|
||||
- **Mobile**: Streamline email viewer header layout
|
||||
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
|
||||
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
|
||||
- **Mailbox**: Improve error message on mailbox creation failure
|
||||
- **Auth**: Skip `checkAuth` on route change when already authenticated
|
||||
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
|
||||
- **UI**: Align hover styles and selection-toggle target with focused item
|
||||
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
|
||||
|
||||
### Refactor
|
||||
|
||||
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
|
||||
|
||||
### Chore
|
||||
|
||||
- **i18n**: Add missing translation keys
|
||||
- **Deps**: Bump dependencies to latest compatible versions
|
||||
|
||||
## 1.4.14 (2026-04-16)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
**One-time**
|
||||
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
- [@boris22100](https://github.com/boris22100)
|
||||
|
||||
**Monthly**
|
||||
|
||||
- [@pr0ton11](https://github.com/pr0ton11)
|
||||
|
||||
### Features
|
||||
|
||||
- **Email**: Add unified mailbox across accounts and sidebar icons toggle
|
||||
- **Email**: Enhance email deletion and spam handling with improved parameterization
|
||||
- **Sieve**: Enhance external rule handling in parser and store (#201)
|
||||
- **Plugins**: Add i18n API, render hooks, and new intercept hooks to plugin system
|
||||
- **PWA**: Dynamic PWA manifest with configurable name, description, and icons
|
||||
- **PWA**: Show app name and logo in install prompt
|
||||
- **i18n**: Add Ukrainian language with flags and missing translation keys
|
||||
- **i18n**: Configurable locale prefix via `NEXT_PUBLIC_LOCALE_PREFIX`
|
||||
- **API**: Add `apiFetch` helper for mount-prefix-aware API calls
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Send iMIP invitation emails when creating or updating calendar events (#192)
|
||||
- **Calendar**: RFC 5545/6047 compliance for outgoing iMIP calendar emails
|
||||
- **Calendar**: Add `calendarAddress` and `replyTo` to participants for Stalwart compatibility (#189, #192)
|
||||
- **Calendar**: Improve CalDAV task detection for external clients like Thunderbird (#84)
|
||||
- **Email**: Hide ICS attachments from attachment list when invitation banner is shown
|
||||
- **Email**: Send before storing in Sent via `onSuccessUpdateEmail` (#188)
|
||||
- **Email**: Standardize tag naming and fix unknown keyword display (#184, #185)
|
||||
- **i18n**: Skip intl middleware for paths already containing a locale prefix
|
||||
- **Docs**: Document PWA and branding env vars in `.env.example`
|
||||
- **Docs**: Use `company` consistently in `.env.example` branding comments
|
||||
|
||||
## 1.4.13 (2026-04-12)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
**One-time**
|
||||
|
||||
- [@boris22100](https://github.com/boris22100)
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
|
||||
**Monthly**
|
||||
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
### Features
|
||||
|
||||
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
|
||||
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
|
||||
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
|
||||
- **PWA**: Add "don't remind me again" option to install prompt
|
||||
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
|
||||
- **Plugins**: Add `onAvatarResolve` plugin hook
|
||||
- **Docker**: Publish main and dev branches as separate GHCR packages
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Email**: Style links in plain text emails
|
||||
- **Email**: Seed list history entry when app initializes on an email view
|
||||
- **Email**: Remount composer on draft edit and preserve identity (#60)
|
||||
- **Contacts**: Display contact names stored in `name.full` (#179)
|
||||
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
|
||||
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
|
||||
- **Search**: Search all folders when filtering emails by tag (#175)
|
||||
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
|
||||
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
|
||||
|
||||
## 1.4.12 (2026-04-09)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
**One-time**
|
||||
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
|
||||
**Monthly**
|
||||
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
### Features
|
||||
|
||||
- **PWA**: Add PWA support with service worker and install prompt
|
||||
- **Calendar**: Add birthday calendar feature with settings and localization
|
||||
- **Calendar**: Clamp February 29 birthdays in non-leap years
|
||||
- **Identity**: Add automatic identity synchronization (#167)
|
||||
- **Plugins**: Disable plugins by default and require admin approval
|
||||
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
|
||||
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
|
||||
- **Email**: Sync mail view to browser history for back/forward navigation
|
||||
- **Contacts**: Add ability to rename address books (#152)
|
||||
- **UI**: Add version badge in settings
|
||||
- **i18n**: Add Latvian (lv) locale support
|
||||
- **i18n**: Add Polish language support
|
||||
- **i18n**: Add Korean language support
|
||||
- **i18n**: Add Simplified Chinese (zh_CN) locale support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
|
||||
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
|
||||
- **Email**: Fix logic for marking email as read in EmailViewer
|
||||
- **Email**: Fix archive action passing MouseEvent as argument
|
||||
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
|
||||
- **Mailbox**: Align shared account folders with primary folders (#151)
|
||||
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
|
||||
- **Mailbox**: Improve mailbox deletion error handling
|
||||
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
|
||||
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
|
||||
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
|
||||
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
|
||||
- **Files**: Prune recent files against server nodes on refresh (#146)
|
||||
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
|
||||
- **Files**: Extend file drop zone to fill remaining viewport height
|
||||
- **Files**: Fallback to application/octet-stream for long MIME types
|
||||
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
|
||||
- **Security**: Validate plugin HTTP post URL against origin with regression tests
|
||||
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
|
||||
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
|
||||
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
|
||||
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
|
||||
- **API**: Handle 403 on principal fetch without console error
|
||||
- **API**: Enhance error handling in Stalwart API responses
|
||||
|
||||
## 1.4.11 (2026-03-31)
|
||||
|
||||
### Features
|
||||
|
||||
- **Logging**: Add logging categories for better log management
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
|
||||
- **Security**: Harden proxy authentication and SSRF defenses
|
||||
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
|
||||
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
|
||||
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
|
||||
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
|
||||
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
|
||||
- **Files**: Update file feature disabled messages and add stability warnings
|
||||
- **i18n**: Add missing translation keys to all non-English locales
|
||||
|
||||
## 1.4.10 (2026-03-31)
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
|
||||
- **Calendar**: Implement client-side recurrence expansion for calendar events
|
||||
- **Calendar**: Add iCal subscription editing and batch event import
|
||||
- **Calendar**: Add hover preview settings and functionality
|
||||
- **Calendar**: Add virtual location input for calendar events (#121)
|
||||
- **Email**: Add reply-to addresses support in email composer
|
||||
- **Email**: Add mail layout settings and update email list components
|
||||
- **Email**: Add auto-select reply identity feature with settings and localization
|
||||
- **Email**: Enhance compose functionality with button integration and translations
|
||||
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
|
||||
- **Filters**: Skip server-managed vacation script in Sieve script handling
|
||||
- **Settings**: Add support for custom JMAP server endpoints in login and settings
|
||||
- **Settings**: Add folder expansion state management and settings navigation
|
||||
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
|
||||
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
|
||||
- **i18n**: Add missing translation keys to all non-English locales
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
|
||||
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
|
||||
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
|
||||
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
|
||||
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
|
||||
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
|
||||
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
|
||||
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
|
||||
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
|
||||
- **Calendar**: Use UTC timestamps for timed event rendering
|
||||
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
|
||||
- **Email**: Enhance email loading and deduplication logic in email store (#119)
|
||||
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
|
||||
- **Email**: Match hover action background to selected row state
|
||||
- **Email**: Align tag counts with mailbox folder counts in sidebar
|
||||
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
|
||||
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
|
||||
- **UI**: Improve dark mode handling for media elements and background images
|
||||
- **UI**: Adjust account list spacing and remove push connection indicator
|
||||
- **UI**: Fix nested button in theme card
|
||||
|
||||
## 1.4.9 (2026-03-27)
|
||||
|
||||
### Features
|
||||
|
||||
- **Admin**: Add Stalwart admin authentication, sidebar access, and a reorganized dashboard with dedicated policy sections
|
||||
- **Plugins**: Add plugin/theme admin dashboard, harness tooling, forced enable or disable controls, managed policy enforcement, and a resizable detail sidebar
|
||||
- **Filters**: Add vacation responder management with Sieve generation and parsing, UI integration, and improved sync preservation
|
||||
- **Email**: Add plain text only composer mode, optional conversation threading disable, configurable hover action placement, and OAuth app password support
|
||||
- **UI**: Add drag-and-drop customization for sidebar apps
|
||||
- **Files**: Use dynamic server-configured maximum upload sizes
|
||||
- **i18n**: Add Russian locale support and complete missing translation strings for recent task features
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Improve date parsing and event normalization, prevent calendar page re-render loops, ensure unique ICal subscription IDs, and create all-day events with correct JSCalendar midnight handling
|
||||
- **Email**: Respect the configured mark-as-read delay in EmailViewer and fetch full email content when needed while editing drafts (#60, #95)
|
||||
- **Auth**: Improve network error handling, add JMAP rate limiting handling, and enhance settings retrieval and persistence diagnostics (#100, #104)
|
||||
- **UI**: Improve mobile layout behavior on contacts and calendar pages (#103)
|
||||
- **Themes**: Repair theme ZIP bundle handling and enforce admin theme locks correctly
|
||||
- **Code Quality**: Resolve outstanding ESLint warnings across the codebase
|
||||
|
||||
## 1.4.8 (2026-03-23)
|
||||
|
||||
### Features
|
||||
|
||||
- **Email**: Add support for marking emails as answered or forwarded and display status icons in email list and thread views
|
||||
- **Email**: Enhance identity selection by supporting sub-addressing (plus addressing) in email composer
|
||||
- **Settings**: Add notification settings with sound picker, preview playback, and configurable alert sounds
|
||||
- **Settings**: Add default mail program settings with localization support across all locales
|
||||
- **Auth**: Implement path prefix handling for OAuth callbacks and login redirects, enabling reverse proxy deployments
|
||||
- **Validation**: Add all multi-part TLDs for domain validation in favicon API (#81)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Fix bugs in duration parsing, RFC compliance, and event handling across calendar components
|
||||
- **Calendar**: Detect tasks created by external CalDAV clients such as Thunderbird
|
||||
- **Settings**: Enhance account settings with username and authentication method display (#90)
|
||||
|
||||
## 1.4.7 (2026-03-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **Calendar**: Add task management features with task creation, editing, and status tracking
|
||||
- **Calendar**: Add option to show week numbers in mini-calendar
|
||||
- **Email**: Add resizable image component and rich text editor with image upload support
|
||||
- **Files**: Support uploading folders via drag-and-drop and toolbar button
|
||||
- **Filters**: Add expanded visual view for filter rules
|
||||
- **Auth**: Add non-interactive SSO login flow for embedded/iframe deployments (#69)
|
||||
- **DevOps**: Add separate Docker build workflow for releases and dev branch images
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Handle updates and deletions for synthetic JMAP IDs in calendar events with fallback to destroy and recreate
|
||||
- **Security**: Extend CryptoEngine to support legacy algorithms and integrate with LinerEngine for decryption
|
||||
- **Auth**: Refactor logout to use synchronous flow with full page redirect
|
||||
- **Email**: Update iframe sandbox attributes to allow popups to escape sandbox
|
||||
- **i18n**: Add missing translation keys across all locales
|
||||
- **Docker**: Update .env.example to clarify Docker volume mounting for settings data directory
|
||||
|
||||
## 1.4.6 (2026-03-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **Demo**: Add full demo mode with fixture data for emails, calendars, contacts, files, filters, identities, mailboxes, and vacation responses
|
||||
- **Demo**: Implement JMAP client interface abstraction to support demo and live backends
|
||||
- **Contacts**: Add no-category filter, drag-and-drop to category, and category combo box in contact form
|
||||
- **Email**: Add hover actions for emails with configurable quick-action buttons
|
||||
- **Settings**: Implement keyword migration functionality for upgrading legacy email tags
|
||||
- **Security**: Enhance S/MIME certificate extraction and add legacy PBE (password-based encryption) support
|
||||
- **Tour**: Add interactive guided tour overlay for new user onboarding
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Settings**: Add missing `showTimeInMonthView` and `showOnMobile` type definitions to settings store
|
||||
- **UI**: Adjust padding and size of sidebar buttons for improved layout
|
||||
|
||||
## 1.4.5 (2026-03-20)
|
||||
|
||||
### Features
|
||||
|
||||
- **Calendar**: Add prev/next navigation buttons and date label to desktop calendar toolbar
|
||||
- **Calendar**: Add pending event preview functionality to calendar views and event modal
|
||||
- **Calendar**: Add setting to show event start time in month view
|
||||
- **Contacts**: Implement pagination for fetching contacts with maxObjectsInGet capability
|
||||
- **Email**: Add attachment position setting in email settings
|
||||
- **Layout**: Add mobile visibility toggle for sidebar apps
|
||||
- **Error**: Add NotFound component to handle 404 errors and redirect unauthenticated users
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Auth**: Enhance account switching logic and clear stores on account change
|
||||
- **Auth**: Improve account restoration logic and handle stale accounts
|
||||
- **Auth**: Improve draft handling in email composer and enhance session cookie verification
|
||||
- **Calendar**: Expand recurring events in CalendarEvent/query so individual occurrences are returned (#65)
|
||||
- **Calendar**: Validate event start field when fetching calendar events
|
||||
- **Calendar**: Auto-scroll agenda view to today's events and include today's date in groups
|
||||
- **Calendar**: Correct JSX syntax in CalendarToolbar component
|
||||
- **Dependencies**: Update flatted to 3.4.2
|
||||
- **DevOps**: Use native ARM runners instead of QEMU for Docker builds
|
||||
- **DevOps**: Enhance health check with detailed memory diagnostics and stable liveness probe
|
||||
|
||||
## 1.4.4 (2026-03-19)
|
||||
|
||||
### Features
|
||||
|
||||
- **Calendar**: Implement CalDAV discovery API with automatic calendar home resolution for multi-account setups
|
||||
- **Calendar**: Enhance calendar management settings with mailbox role reassignment controls
|
||||
- **Email**: Add signature rendering utilities with HTML-to-text conversion and sanitization
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Auth**: Fix account session handling to update existing accounts instead of duplicating entries
|
||||
- **Auth**: Fix logout redirects and unauthenticated home page rendering
|
||||
- **Calendar**: Fix duplicate calendar edits and prevent double-save submissions in event modal
|
||||
- **Calendar**: Remove stale calendar ID references in favor of CalDAV-discovered IDs
|
||||
- **Contacts**: Improve RFC 9553 compliance for contact birthdays and address formatting
|
||||
- **Email**: Fix email signature rendering for identity signatures
|
||||
- **Folders**: Improve mailbox role management by clearing roles from all mailboxes before reassigning
|
||||
|
||||
## 1.4.3 (2026-03-19)
|
||||
|
||||
### Features
|
||||
|
||||
- **Auth**: Implement multi-account support with up to 5 simultaneous accounts and instant switching
|
||||
- **Auth**: Add account switcher component with connection status, default account selection, and per-account logout
|
||||
- **Auth**: Support multi-account OAuth and basic auth with per-account session persistence
|
||||
- **Contacts**: Enhance contacts sidebar with collapsible sections, bulk operations, and address book grouping
|
||||
- **Contacts**: Add contact import functionality and keyword filtering
|
||||
- **Settings**: Add per-account encrypted settings storage with server-side sync support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **UI**: Adjust popover alignment in sub-address helper component
|
||||
- **Settings**: Improve error logging in settings sync functionality
|
||||
|
||||
## 1.4.2 (2026-03-19)
|
||||
|
||||
### Features
|
||||
|
||||
- **Calendar**: Add task list view for calendar tasks with task details and management
|
||||
- **Calendar**: Add shared calendar grouping with visual separation in sidebar
|
||||
- **Calendar**: Support double-click to create events and improve modal date handling
|
||||
- **Contacts**: Add address book directories with drag-and-drop and editor picker
|
||||
- **Email**: Add email attachment support in sendEmail functionality
|
||||
- **Email**: Implement draft editing functionality across email components
|
||||
- **Email**: Implement unwrapping of embedded message/rfc822 attachments with enhanced HTML body validation
|
||||
- **Email**: Add email export/import localization keys for multiple languages
|
||||
- **Contacts**: Update gender handling to use speakToAs structure
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Email**: Resolve default sender to canonical identity on local-part login
|
||||
- **Email**: Refactor overflow handling in EmailViewer to use hidden priorities and layout effects
|
||||
- **Email**: Remove debugMode usage from EmailViewer component
|
||||
- **Calendar**: Enhance IMIP invitation and cancellation handling for calendar events
|
||||
- **Calendar**: Add time-based sorting for events in buildWeekSegments function
|
||||
- **Dependencies**: Update dompurify to 3.3.3 and elliptic to 6.6.1, add undici override
|
||||
|
||||
## 1.4.1 (2026-03-18)
|
||||
|
||||
### Features
|
||||
|
||||
- **Security**: Add S/MIME certificate management with identity bindings, signer auto-import, unlock controls, and compose/viewer sign, encrypt, decrypt, and verification flows
|
||||
- **Email**: Add TNEF (`winmail.dat`) parsing to extract message bodies and attachments from Outlook rich-text emails
|
||||
- **Email**: Add archive organization modes for archiving directly or into year/month subfolders
|
||||
- **Email**: Add an "Always Show Emails in Light Mode" preference to avoid dark-mode conversion issues
|
||||
- **Email**: Apply the 12-hour or 24-hour time format preference consistently across calendar and email surfaces
|
||||
- **Identity**: Add identity refresh behavior in the identity manager so server-side changes stay in sync after edits
|
||||
- **UI**: Add configurable sidebar apps with custom icons plus inline or new-tab launch modes
|
||||
- **Branding**: Add runtime branding options for custom favicon, sidebar logos, and login logos
|
||||
- **Deployment**: Add configurable server listen address support via `HOSTNAME`, including IPv6 and dual-stack guidance
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Improve all-day event handling
|
||||
- **Calendar**: Validate and default persisted calendar view mode values
|
||||
- **UI**: Use configured app names more consistently in metadata and login branding surfaces
|
||||
- **Docker**: Correct `HOSTNAME` formatting in the Docker Compose example
|
||||
- **Metadata**: Correct package author and container vendor metadata
|
||||
|
||||
## 1.3.0 (2026-03-16)
|
||||
|
||||
### Features
|
||||
|
||||
- **Calendar**: RSVP support for calendar invitations with trust assessment
|
||||
- **Calendar**: iCal/webcal subscription support
|
||||
- **Calendar**: Create, update, and delete calendar events
|
||||
- **Calendar**: Enhanced EventModal with alert and recurrence labels, view/edit mode toggle
|
||||
- **Email**: Iframe-based email rendering with smart dark mode support
|
||||
- **Email**: Security tooltips, contact actions, and scroll improvements in email viewer
|
||||
- **Email**: Improved message details and contact sidebar in email viewer
|
||||
- **Email**: Move-to mailbox functionality in email viewer
|
||||
- **Email**: Mobile bottom action bar with reply and email navigation
|
||||
- **Email**: Auto-fetch full email content when an email is auto-selected
|
||||
- **Email**: Unread filter functionality in mailbox sidebar
|
||||
- **Email**: Empty folder functionality for junk and trash mailboxes with confirmation dialog
|
||||
- **Files**: JMAP FileNode file storage backend and file settings
|
||||
- **Files**: File preview support
|
||||
- **Contacts**: Enhanced contacts management with sidebar and selection features
|
||||
- **Contacts**: Import/export functionality in contacts settings
|
||||
- **Contacts**: Improved contact group management with UID normalization
|
||||
- **Settings**: Tab icons and grouping with improved file settings preview
|
||||
- **Settings**: Extra-compact density option and font size scaling
|
||||
- **Settings**: Logout button in settings
|
||||
- **UI**: Sidebar resizing across calendar, contacts, and settings pages
|
||||
- **UI**: Keyboard shortcuts button and show/hide toolbar labels option
|
||||
- **UI**: Recursive depth calculation for mailbox tree structure
|
||||
- **UI**: Mobile long-press context menu
|
||||
- **i18n**: Expanded supported locales
|
||||
- **API**: Enhanced configuration fetching with retry logic
|
||||
- **License**: Updated to AGPL-3.0-only with NOTICE file for fork lineage
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Correct all-day multi-day event rendering
|
||||
- **Email**: Adjust text wrapping for email subject in EmailViewer and ThreadConversationView
|
||||
- **Email**: Adjust email content area layout for better responsiveness
|
||||
- **Email**: Sync identity stores and append signatures to outgoing emails (#15)
|
||||
- **Contacts**: Handle non-string anniversary dates in contact detail
|
||||
- **UI**: Fix nested button hydration error in sidebar mailbox tree
|
||||
- **UI**: Update sidebar border styling for consistency across pages
|
||||
- **UI**: Update avatar background color logic based on image source
|
||||
- **UI**: Make density setting functional across entire UI
|
||||
|
||||
## 1.2.4 (2026-03-14)
|
||||
|
||||
### Features
|
||||
|
||||
- **Tags**: Show total and unread email counts next to each tag in the sidebar
|
||||
- **Tags**: Instant tag count refresh after adding or removing a tag
|
||||
- **Search**: Wildcard query functionality for enhanced search capabilities
|
||||
- **Search**: Support OR conditions across multiple fields in email search
|
||||
- **Search**: Improved search results display with pluralization and localization
|
||||
- **Email**: Dropdown menus for actions in email viewer
|
||||
- **Email**: Improved email list messages for clarity and localization
|
||||
- **Email**: Enhanced unsubscribe banner with destructive color styling
|
||||
- **Auth**: Centralized Stalwart credentials management
|
||||
- **Login**: Configurable logo with light/dark mode support
|
||||
- **Avatar**: Dev mode configuration for profile picture retrieval
|
||||
- **DevOps**: Added `.env.dev.example` for development configuration
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Email**: Prevent browser auth dialog when viewing emails with inline images
|
||||
- **Login**: Optimize theme store usage with shallow comparison
|
||||
- **Git**: Add local private data directory to `.gitignore`
|
||||
|
||||
## 1.2.3 (2026-03-13)
|
||||
|
||||
### Features
|
||||
|
||||
- **Calendar**: Hover functionality for calendar events with preview popover
|
||||
- **Contacts**: Enhanced contact management and vCard support
|
||||
- **Email**: Tagging system with color labels and drag-and-drop tag support
|
||||
- **Email**: Multi-select with checkbox functionality and batch operations
|
||||
- **Email**: Recipient popover for contact interaction
|
||||
- **Email**: Inline search filters and folder icon picker
|
||||
- **Email**: Sender favicon avatars with negative caching for performance
|
||||
- **Email**: Print functionality for email content
|
||||
- **Folders**: Folder management settings with CRUD, standard role assignment, and icon picker
|
||||
- **Folders**: Subfolder creation and hierarchical navigation
|
||||
- **Settings**: Settings synchronization with server (encrypted API endpoints)
|
||||
- **Settings**: Toolbar position customization and mobile layout tabs
|
||||
- **Settings**: Login page customization options
|
||||
- **Account Security**: Stalwart account security management panel
|
||||
- **OAuth2/OIDC**: OAuth-only login mode
|
||||
- **UI**: Resizable columns, navigation rail overhaul, and drag-and-drop email organization
|
||||
- **UI**: Toast notifications with enter/exit animations and progress bar
|
||||
- **UI**: Responsive mobile layout with bottom tab bar and tablet support
|
||||
- **i18n**: Added Dutch and Portuguese translations (now 8 languages)
|
||||
- **Docker**: Publish only to GHCR, remove Docker Hub
|
||||
- **DevOps**: Interactive setup script with dry-run option and JMAP server URL validation
|
||||
- **Branding**: New Bulwark Webmail identity with logo assets and light/dark mode support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Sieve**: Use `onSuccessActivateScript` for sieve activation (#21)
|
||||
- **Composer**: Fix trailing comma handling in recipient input
|
||||
- **Email**: Fix print functionality for email content
|
||||
- **Connection**: Connection loss handling with session recovery
|
||||
- **Redirect**: Login redirect functionality with sessionStorage error handling
|
||||
|
||||
## 1.1.2 (2026-03-02)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
||||
- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/*, globals, @types/node
|
||||
- **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
||||
|
||||
## 1.1.1 (2026-02-28)
|
||||
|
||||
@@ -15,7 +718,7 @@
|
||||
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
||||
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
||||
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||
|
||||
## 1.1.0 (2026-02-28)
|
||||
|
||||
|
||||
+37
-9
@@ -1,23 +1,47 @@
|
||||
# Contributing to JMAP Webmail
|
||||
<div align="center">
|
||||
|
||||
Thank you for your interest in contributing to JMAP Webmail! This document provides guidelines and information for contributors.
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
|
||||
# Contributing to Bulwark Webmail
|
||||
|
||||
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
||||
|
||||
## Join our Community
|
||||
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
|
||||
|
||||
* **Get Support:** Get real-time help with development hurdles.
|
||||
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
|
||||
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
|
||||
|
||||
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Fork and clone** the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/root-fr/jmap-webmail.git
|
||||
cd jmap-webmail
|
||||
git clone https://github.com/bulwarkmail/webmail.git
|
||||
cd webmail
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Set up environment**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your JMAP server URL
|
||||
@@ -75,9 +99,10 @@ This project uses **next-intl** for internationalization. Please follow these gu
|
||||
### Key Rules
|
||||
|
||||
1. **Never hardcode user-facing text** - Always use translations:
|
||||
|
||||
```tsx
|
||||
const t = useTranslations('namespace');
|
||||
return <div>{t('key')}</div>;
|
||||
const t = useTranslations("namespace");
|
||||
return <div>{t("key")}</div>;
|
||||
```
|
||||
|
||||
2. **Translation file locations**:
|
||||
@@ -109,6 +134,7 @@ This project uses **next-intl** for internationalization. Please follow these gu
|
||||
### Before Submitting
|
||||
|
||||
1. **Create a feature branch**:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
@@ -147,6 +173,7 @@ Follow the conventional commits format:
|
||||
- `chore:` - Maintenance tasks
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
feat: add email threading support
|
||||
fix: resolve attachment download issue
|
||||
@@ -156,7 +183,7 @@ docs: update README with keyboard shortcuts
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
jmap-webmail/
|
||||
webmail/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ └── [locale]/ # Locale-aware routing
|
||||
├── components/ # React components
|
||||
@@ -179,12 +206,13 @@ jmap-webmail/
|
||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||
- **Sanitize user input** and email content
|
||||
- **Block external content** by default for privacy
|
||||
- Report security vulnerabilities privately
|
||||
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to:
|
||||
|
||||
- Open an issue for discussion
|
||||
- Check existing issues and pull requests
|
||||
|
||||
Thank you for helping improve JMAP Webmail!
|
||||
Thank you for helping improve Bulwark Webmail!
|
||||
|
||||
+14
-5
@@ -4,16 +4,24 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
|
||||
# at build time, so it cannot be changed without rebuilding.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
RUN npx next build --webpack
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
|
||||
LABEL org.opencontainers.image.title="JMAP Webmail"
|
||||
LABEL org.opencontainers.image.title="Bulwark Webmail"
|
||||
LABEL org.opencontainers.image.description="Modern webmail client built with Next.js and the JMAP protocol"
|
||||
LABEL org.opencontainers.image.source="https://github.com/root-fr/jmap-webmail"
|
||||
LABEL org.opencontainers.image.url="https://github.com/root-fr/jmap-webmail"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="root.cloud"
|
||||
LABEL org.opencontainers.image.source="https://github.com/bulwarkmail/webmail"
|
||||
LABEL org.opencontainers.image.url="https://github.com/bulwarkmail/webmail"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-only"
|
||||
LABEL org.opencontainers.image.vendor="rbm.systems"
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
@@ -26,6 +34,7 @@ RUN apk upgrade --no-cache && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# Features
|
||||
|
||||
## Mail
|
||||
|
||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding)
|
||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||
- Unified mailbox view across all connected accounts
|
||||
- Draft auto-save with identity preservation
|
||||
- Attachment upload, download, and inline preview; forgotten-attachment warning
|
||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Batch operations – multi-select, archive, delete, move, tag
|
||||
- Archive modes – direct, by year, or by month
|
||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
||||
- Star/unstar with configurable mark-as-read delay
|
||||
- Virtual scrolling for large mailboxes
|
||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||
- Plain-text composer mode and Reply-To support
|
||||
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||
- Folder management with icon picker, subfolders, and sidebar counts
|
||||
- Print directly from the viewer
|
||||
- Browser history sync for back/forward navigation
|
||||
|
||||
## Calendar
|
||||
|
||||
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
||||
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
||||
- Recurring events with scoped edit/delete (this / this and following / all)
|
||||
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
||||
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
||||
- iCalendar import with preview, bulk create, and UID deduplication
|
||||
- iCal / webcal subscriptions with editing and batch import
|
||||
- Auto-generated birthday calendar from contacts
|
||||
- Virtual locations (video conference URLs) as first-class event fields
|
||||
- Task management with due dates, priority, and completion status
|
||||
- Shared calendars with CalDAV discovery and multi-account home resolution
|
||||
- Week numbers, event hover preview, notifications with sound picker
|
||||
- Real-time sync via JMAP push
|
||||
|
||||
## Contacts
|
||||
|
||||
- JMAP sync (RFC 9553 / 9610) with local fallback
|
||||
- Multiple address books with drag-and-drop between books
|
||||
- Contact groups with member management
|
||||
- vCard import/export (RFC 6350) with duplicate detection
|
||||
- Trusted senders stored in a dedicated JMAP address book
|
||||
- Autocomplete in the composer (To / Cc / Bcc)
|
||||
|
||||
## Filters & Templates
|
||||
|
||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||
- Preserves rules authored in other clients
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Vacation responder with date range scheduling
|
||||
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||
|
||||
## Files
|
||||
|
||||
- JMAP FileNode browser (Stalwart native cloud storage)
|
||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||
- Dynamic upload limits based on server configuration
|
||||
- Grid and list views with sorting by name, size, or date
|
||||
- Previews for images, text, audio, and video
|
||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators
|
||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- TOTP two-factor authentication
|
||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
||||
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
||||
- Plugin hardening with dangerous-pattern detection and admin approval
|
||||
- Newsletter unsubscribe (RFC 2369)
|
||||
|
||||
## Interface
|
||||
|
||||
- Three-pane layout with resizable columns
|
||||
- Dark and light themes with intelligent email color transformation
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Full keyboard navigation
|
||||
- Drag-and-drop email organization and tag assignment
|
||||
- Interactive guided tour for new users
|
||||
- Right-click context menus, toast notifications with undo
|
||||
- Customizable toolbar position, favicon, and login branding
|
||||
- Pinnable sidebar apps with drag-and-drop reordering
|
||||
- Encrypted settings sync across devices
|
||||
- Storage quota display
|
||||
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
|
||||
|
||||
## Internationalization
|
||||
|
||||
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
## Identity & Multi-Account
|
||||
|
||||
- Up to 5 simultaneous accounts with instant switching and per-account session persistence
|
||||
- Account switcher with connection status and default account selection
|
||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions
|
||||
- Shared folders across accounts
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
## Admin & Extensibility
|
||||
|
||||
- Stalwart admin dashboard with dedicated policy sections
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
|
||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||
- Bundled plugins including Jitsi Meet calendar integration
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, and dynamic manifest
|
||||
- Automatic update check with server-side logging of new releases
|
||||
- Structured logging (`text` or `json`) with category-based levels
|
||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||
- Demo mode with fixture data – no mail server required
|
||||
@@ -1,3 +1,760 @@
|
||||
Bulwark Webmail
|
||||
Copyright (C) 2025-2026 Bulwark Project Authors
|
||||
|
||||
This project is licensed under the GNU Affero General Public License,
|
||||
version 3 only.
|
||||
|
||||
This repository includes material derived from earlier MIT-licensed work.
|
||||
The original attribution notice preserved for the fork lineage is available
|
||||
in the NOTICE file.
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, version 3 of the License.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
# Bulwark Webmail fork notice
|
||||
|
||||
This repository is distributed under the GNU Affero General Public License,
|
||||
version 3 only. It also includes material derived from earlier MIT-licensed
|
||||
work in the fork lineage.
|
||||
|
||||
Fork lineage:
|
||||
|
||||
- https://github.com/root-fr/jmap-webmail
|
||||
- https://github.com/bulwarkmail/webmail
|
||||
|
||||
Original MIT attribution notice preserved from the earlier forked codebase:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Matthieu MALVACHE
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Bulwark Webmail fork notice
|
||||
|
||||
This repository is distributed under the GNU Affero General Public License,
|
||||
version 3 only. It also includes material derived from earlier MIT-licensed
|
||||
work in the fork lineage.
|
||||
|
||||
Fork lineage:
|
||||
|
||||
- https://github.com/root-fr/jmap-webmail
|
||||
- https://github.com/bulwarkmail/webmail
|
||||
|
||||
Original MIT attribution notice preserved from the earlier forked codebase:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Matthieu MALVACHE
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,315 +1,283 @@
|
||||
# JMAP Webmail
|
||||
<div align="center">
|
||||
|
||||
A webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
## Why Stalwart?
|
||||
# Bulwark Webmail
|
||||
|
||||
Stalwart is a mail server written in Rust with native JMAP support, not IMAP/SMTP with JMAP added as an afterthought. It handles JMAP, IMAP, SMTP, and ManageSieve. Self-hosted, no third-party dependencies.
|
||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||
|
||||
[Stalwart on GitHub](https://github.com/stalwartlabs/mail-server) | [Documentation](https://stalw.art/docs/)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
## Features
|
||||
</div>
|
||||
|
||||
### Email
|
||||
---
|
||||
|
||||
- Read, compose, reply, reply-all, and forward
|
||||
- HTML rendering with DOMPurify sanitization
|
||||
- Attachment upload and download
|
||||
- Draft auto-save with discard confirmation
|
||||
- Threading with inline expansion
|
||||
- Mark as read/unread, star/unstar
|
||||
- Archive and delete with configurable behavior
|
||||
- Color tags/labels
|
||||
- Search with JMAP filter panel, search chips, cross-mailbox queries
|
||||
- Virtual scrolling for large lists
|
||||
## Screenshots
|
||||
|
||||
### Interface
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
|
||||
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
|
||||
</picture>
|
||||
|
||||
- Three-pane layout with dark and light themes
|
||||
- Responsive (desktop sidebar + mobile bottom tab bar)
|
||||
- Keyboard shortcuts
|
||||
- Drag-and-drop email organization
|
||||
- Right-click context menus
|
||||
- Animations that respect `prefers-reduced-motion`
|
||||
- Infinite scroll pagination
|
||||
- Toast notifications with undo support
|
||||
- Form validation with shake feedback
|
||||
- Safe area insets for notched devices
|
||||
- Screen reader live regions
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
|
||||
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Calendar</b> – month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
|
||||
<td><sub><b>Contacts</b> – multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/theme.png" alt="Themes" /></td>
|
||||
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Themes</b> – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
|
||||
<td><sub><b>Plugins</b> – extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
|
||||
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Light mode</b> – full theme support with intelligent color transformation for HTML emails.</sub></td>
|
||||
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Real-time
|
||||
## Overview
|
||||
|
||||
- Push notifications via JMAP EventSource
|
||||
- Live unread counts
|
||||
- Email arrival notifications
|
||||
- Connection status indicator
|
||||
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||
|
||||
### Identity management
|
||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
- Multiple sender identities with per-identity signatures
|
||||
- Sub-addressing (user+tag@domain.com) with tag suggestions
|
||||
- Identity badges in viewer and list
|
||||
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
|
||||
|
||||
### Address book
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
- Contact management with search and filtering
|
||||
- JMAP server sync (RFC 9553/9610) with local fallback
|
||||
- Email autocomplete in composer
|
||||
- Contact groups with group expansion
|
||||
- vCard import/export (RFC 6350) with duplicate detection
|
||||
- Bulk operations (multi-select, delete, group add, export)
|
||||
---
|
||||
|
||||
### Calendar
|
||||
## Quick Start
|
||||
|
||||
- JMAP Calendar (RFC 8984) with capability detection
|
||||
- Month, week, day, and agenda views
|
||||
- Event create, edit, delete with recurrence and reminders
|
||||
- Participant scheduling with iTIP invitations and RSVP
|
||||
- Inline calendar invitation banner in email viewer (.ics detection, RSVP, import)
|
||||
- Multi-day events, column-based overlap layout
|
||||
- Mini-calendar sidebar with calendar visibility toggles
|
||||
- Locale-aware date formatting
|
||||
- Settings for first day of week, time format (12h/24h), default view
|
||||
- Drag-and-drop rescheduling with time snap
|
||||
- Click-drag on empty slots to create events
|
||||
- Resize events by dragging (15-minute snap)
|
||||
- Double-click quick create
|
||||
- Event duplication (+1 day offset)
|
||||
- Recurring event edit/delete scope (this, this and following, all)
|
||||
- iCalendar (.ics) file import with preview
|
||||
- Real-time updates via JMAP push
|
||||
- Event notifications with configurable sound
|
||||
|
||||
### Email templates
|
||||
|
||||
- Reusable templates organized by category
|
||||
- Placeholder variables (`{{recipientName}}`, `{{date}}`, etc.) with auto-fill
|
||||
- Template picker in compose toolbar with search and filter
|
||||
- Template manager in settings
|
||||
|
||||
### Email filters
|
||||
|
||||
- Server-side filtering with JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder: conditions (From, To, Subject, Size, Body...) and actions (Move, Forward, Mark read, Star, Discard, Reject...)
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Auto-save with rollback on failure
|
||||
- Drag-and-drop rule reordering
|
||||
- Only shown when the server supports Sieve
|
||||
|
||||
### Vacation responder
|
||||
|
||||
- JMAP VacationResponse with date range scheduling
|
||||
- Settings tab for message configuration
|
||||
- Sidebar indicator when active
|
||||
|
||||
### Security and privacy
|
||||
|
||||
- External content blocked by default
|
||||
- Trusted senders list for automatic image loading
|
||||
- HTML sanitization (DOMPurify)
|
||||
- SPF/DKIM/DMARC status indicators
|
||||
- Session-based auth, no password storage by default
|
||||
- TOTP two-factor authentication
|
||||
- "Remember me" with AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||
- OAuth2/OIDC with PKCE for SSO (opt-in, RP-initiated logout)
|
||||
- External IdP support (Keycloak, Authentik) via configurable issuer URL
|
||||
- CORS misconfiguration detection with detailed error messages
|
||||
- Shared folder support
|
||||
- Newsletter unsubscribe (RFC 2369)
|
||||
- CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy headers
|
||||
|
||||
### Internationalization
|
||||
|
||||
- 8 languages: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese
|
||||
- Automatic browser language detection
|
||||
- Persistent language preference
|
||||
|
||||
### Deployment
|
||||
|
||||
- Pre-built Docker images on [Docker Hub](https://hub.docker.com/r/rootfr/jmap-webmail) and [GHCR](https://ghcr.io/root-fr/jmap-webmail) (amd64/arm64)
|
||||
- Multi-stage build with standalone output
|
||||
- Runtime environment variables (no rebuild needed)
|
||||
- Health check endpoint
|
||||
- Structured logging (text/JSON)
|
||||
- Update check on startup (server logs only, no client exposure)
|
||||
|
||||
## Tech stack
|
||||
|
||||
- [Next.js 16](https://nextjs.org/) with App Router
|
||||
- TypeScript
|
||||
- [Tailwind CSS v4](https://tailwindcss.com/)
|
||||
- [Zustand](https://zustand-demo.pmnd.rs/) for state management
|
||||
- Custom JMAP client (RFC 8620)
|
||||
- [next-intl](https://next-intl-docs.vercel.app/) for i18n
|
||||
- [Lucide React](https://lucide.dev/) icons
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- A JMAP-compatible mail server ([Stalwart](https://stalw.art/) recommended)
|
||||
|
||||
### Installation
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
git clone https://github.com/root-fr/jmap-webmail.git
|
||||
cd jmap-webmail
|
||||
docker run -d -p 3000:3000 \
|
||||
-e JMAP_SERVER_URL=https://mail.example.com \
|
||||
ghcr.io/bulwarkmail/webmail:latest
|
||||
```
|
||||
|
||||
Or with Docker Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local – set JMAP_SERVER_URL
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bulwarkmail/webmail.git
|
||||
cd webmail
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local – set JMAP_SERVER_URL
|
||||
npm run build && npm start
|
||||
```
|
||||
|
||||
### Configuration
|
||||
### Development
|
||||
|
||||
Edit `.env.local`:
|
||||
```bash
|
||||
npm run dev # Dev server with a mock JMAP server
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||
|
||||
```env
|
||||
# App name displayed in the UI
|
||||
APP_NAME=My Webmail
|
||||
|
||||
# Your JMAP server URL (required)
|
||||
# Required
|
||||
JMAP_SERVER_URL=https://mail.example.com
|
||||
|
||||
# Optional
|
||||
APP_NAME=My Webmail
|
||||
```
|
||||
|
||||
These are runtime environment variables, read at request time. Docker deployments can be configured without rebuilding. Legacy `NEXT_PUBLIC_*` variables still work as fallbacks.
|
||||
<details>
|
||||
<summary>Server listen address</summary>
|
||||
|
||||
#### OAuth2/OIDC (optional)
|
||||
```env
|
||||
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
To enable SSO login alongside Basic Auth:
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>OAuth2 / OIDC</summary>
|
||||
|
||||
```env
|
||||
OAUTH_ENABLED=true
|
||||
OAUTH_CLIENT_ID=webmail
|
||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
||||
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs
|
||||
```
|
||||
|
||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. If your JMAP server delegates auth to an external IdP, set `OAUTH_ISSUER_URL` to the IdP's base URL (e.g., `https://keycloak.example.com/realms/mail`).
|
||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
||||
|
||||
#### Remember me (optional)
|
||||
</details>
|
||||
|
||||
To enable "Remember me" for Basic Auth login:
|
||||
<details>
|
||||
<summary>Session & settings sync</summary>
|
||||
|
||||
```env
|
||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
||||
SESSION_SECRET= # openssl rand -base64 32
|
||||
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
|
||||
|
||||
SETTINGS_SYNC_ENABLED=true
|
||||
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
|
||||
```
|
||||
|
||||
When set, a "Remember me" checkbox appears on the login form. Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry).
|
||||
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||
|
||||
### Development
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Custom JMAP endpoint</summary>
|
||||
|
||||
```env
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||
```
|
||||
|
||||
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Branding & PWA</summary>
|
||||
|
||||
```env
|
||||
APP_NAME=My Webmail
|
||||
APP_SHORT_NAME=Webmail
|
||||
APP_DESCRIPTION=Your personal mail
|
||||
|
||||
FAVICON_URL=/branding/favicon.svg
|
||||
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
|
||||
PWA_THEME_COLOR=#3b82f6
|
||||
PWA_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
|
||||
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
|
||||
|
||||
LOGIN_COMPANY_NAME=My Company
|
||||
LOGIN_WEBSITE_URL=https://example.com
|
||||
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Extension directory</summary>
|
||||
|
||||
```env
|
||||
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
```
|
||||
|
||||
Enables the admin marketplace for browsing and installing plugins and themes.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Stalwart integration & logging</summary>
|
||||
|
||||
```env
|
||||
STALWART_FEATURES=true # password change, Sieve filters, etc.
|
||||
|
||||
LOG_FORMAT=text # "text" or "json"
|
||||
LOG_LEVEL=info # error | warn | info | debug
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Subpath / reverse proxy mount</summary>
|
||||
|
||||
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_BASE_PATH=/webmail
|
||||
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
|
||||
```
|
||||
|
||||
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server
|
||||
npm run typecheck # Type checking
|
||||
npm run lint # Linting
|
||||
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
|
||||
```
|
||||
|
||||
### Production
|
||||
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
</details>
|
||||
|
||||
### Docker
|
||||
## Keyboard Shortcuts
|
||||
|
||||
```bash
|
||||
# Pre-built image
|
||||
docker run -p 3000:3000 -e JMAP_SERVER_URL=https://mail.example.com rootfr/jmap-webmail:latest
|
||||
| Key | Action |
|
||||
| ------------- | ----------------------- |
|
||||
| `j` / `k` | Navigate between emails |
|
||||
| `Enter` / `o` | Open email |
|
||||
| `Esc` | Close / deselect |
|
||||
| `c` | Compose |
|
||||
| `r` / `R` | Reply / Reply all |
|
||||
| `f` | Forward |
|
||||
| `s` | Star |
|
||||
| `e` | Archive |
|
||||
| `#` | Delete |
|
||||
| `/` | Search |
|
||||
| `?` | Show all shortcuts |
|
||||
|
||||
# From GHCR
|
||||
docker run -p 3000:3000 -e JMAP_SERVER_URL=https://mail.example.com ghcr.io/root-fr/jmap-webmail:latest
|
||||
## Tech Stack
|
||||
|
||||
# With docker compose
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your JMAP_SERVER_URL
|
||||
docker compose up -d
|
||||
| | |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router |
|
||||
| **Language** | TypeScript |
|
||||
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
|
||||
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
|
||||
| **Protocol** | Custom JMAP client (RFC 8620) |
|
||||
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
|
||||
| **Icons** | [Lucide React](https://lucide.dev/) |
|
||||
|
||||
# Build from source
|
||||
docker build -t jmap-webmail .
|
||||
docker run -p 3000:3000 -e JMAP_SERVER_URL=https://mail.example.com jmap-webmail
|
||||
```
|
||||
## Why Stalwart?
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `j` / `k` | Navigate between emails |
|
||||
| `Enter` / `o` | Open selected email |
|
||||
| `Esc` | Close viewer / deselect |
|
||||
| `c` | Compose new email |
|
||||
| `r` | Reply |
|
||||
| `R` / `a` | Reply all |
|
||||
| `f` | Forward |
|
||||
| `s` | Toggle star |
|
||||
| `e` | Archive |
|
||||
| `#` / `Delete` | Delete |
|
||||
| `u` | Mark as unread |
|
||||
| `/` | Focus search |
|
||||
| `x` | Expand/collapse thread |
|
||||
| `Ctrl+Shift+T` | Insert template |
|
||||
| `?` | Show shortcuts help |
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
**Login**
|
||||
<img src="screenshots/01-login.png" width="100%" alt="Login">
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
**Inbox**
|
||||
<img src="screenshots/02-inbox.png" width="100%" alt="Inbox">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
**Email Viewer**
|
||||
<img src="screenshots/03-email-viewer.png" width="100%" alt="Email Viewer">
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
**Compose**
|
||||
<img src="screenshots/04-compose.png" width="100%" alt="Compose">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
**Dark Mode**
|
||||
<img src="screenshots/05-dark-mode.png" width="100%" alt="Dark Mode">
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
**Settings**
|
||||
<img src="screenshots/06-settings.png" width="100%" alt="Settings">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support – not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for planned features.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [Stalwart Labs](https://stalw.art/) for the mail server
|
||||
- The [JMAP](https://jmap.io/) working group for the protocol spec
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
|
||||
|
||||
-269
@@ -1,269 +0,0 @@
|
||||
# JMAP Webmail - Roadmap
|
||||
|
||||
This document tracks the development status and planned features for JMAP Webmail.
|
||||
|
||||
## Completed Features
|
||||
|
||||
### Core Infrastructure
|
||||
- [x] Next.js 16 with TypeScript and App Router
|
||||
- [x] Tailwind CSS v4 with Oxide engine
|
||||
- [x] Zustand state management
|
||||
- [x] Custom JMAP client implementation (RFC 8620)
|
||||
|
||||
### Authentication
|
||||
- [x] Login with JMAP server authentication
|
||||
- [x] Session management (no password storage for security)
|
||||
- [x] Username autocomplete with history
|
||||
- [x] Logout functionality
|
||||
- [x] Authentication error handling
|
||||
- [x] JMAP identities for sender address
|
||||
- [x] TOTP two-factor authentication (Stalwart-compatible)
|
||||
- [x] OAuth2/OIDC with PKCE (opt-in SSO, session persistence, RP-initiated logout)
|
||||
- [x] External IdP support via explicit issuer URL (Keycloak, Authentik, etc.)
|
||||
- [x] "Remember me" session persistence for Basic Auth (AES-256-GCM encrypted httpOnly cookie)
|
||||
|
||||
### JMAP Server Connection
|
||||
- [x] Session establishment and keep-alive
|
||||
- [x] Connection error handling and retries
|
||||
- [x] CORS error detection with actionable user guidance
|
||||
- [x] Session URL origin rewriting (fixes Docker/reverse proxy deployments where server returns internal hostname)
|
||||
- [x] Storage quota display
|
||||
- [x] Server capability detection
|
||||
- [x] Shared folders support (multi-account access)
|
||||
|
||||
### Email Operations
|
||||
- [x] Email fetching and display
|
||||
- [x] Full HTML email rendering
|
||||
- [x] Compose, reply, reply-all, forward
|
||||
- [x] Draft auto-save with discard confirmation
|
||||
- [x] Mark as read/unread
|
||||
- [x] Star/unstar emails
|
||||
- [x] Delete and archive
|
||||
- [x] Color tags/labels
|
||||
- [x] Full-text search
|
||||
- [x] Advanced search with JMAP filter panel, search chips, and cross-mailbox queries
|
||||
- [x] Attachment upload and download
|
||||
- [x] Batch operations (multi-select)
|
||||
- [x] Quick reply form
|
||||
- [x] Email threading (Gmail-style inline expansion)
|
||||
|
||||
### Real-time Updates
|
||||
- [x] EventSource for JMAP push notifications
|
||||
- [x] State synchronization
|
||||
- [x] Email arrival notifications
|
||||
- [x] Real-time unread counts
|
||||
- [x] Mailbox change handling
|
||||
|
||||
### User Interface
|
||||
- [x] Three-pane layout (sidebar, list, viewer)
|
||||
- [x] Minimalist design system
|
||||
- [x] Dark and light theme support
|
||||
- [x] Custom scrollbars
|
||||
- [x] Mobile responsive design
|
||||
- [x] Keyboard shortcuts
|
||||
- [x] Drag-and-drop email organization
|
||||
- [x] Right-click context menus
|
||||
- [x] Hierarchical mailbox display
|
||||
- [x] Email list with avatars and visual hierarchy
|
||||
- [x] Expandable email headers
|
||||
- [x] External content warning banner
|
||||
- [x] SPF/DKIM/DMARC status indicators
|
||||
- [x] Loading states and skeletons
|
||||
- [x] Smooth transitions and animations
|
||||
- [x] Infinite scroll pagination
|
||||
- [x] Virtual scrolling for large email lists
|
||||
- [x] Error boundaries
|
||||
- [x] Settings page with preferences
|
||||
- [x] Navigation rail (desktop vertical icon sidebar + mobile bottom tab bar)
|
||||
- [x] Welcome banner for first-time users (one-time display, localStorage persistence)
|
||||
- [x] Confirmation dialog component with promise-based useConfirmDialog hook
|
||||
- [x] Toast notifications with undo action support and typed durations
|
||||
- [x] Inline form validation with shake animation (email composer, contact form)
|
||||
- [x] Login UX polish (error shake, discreet 2FA toggle, password visibility toggle, session expired banner)
|
||||
- [x] Empty state patterns for contacts (distinct "no data" vs "no search results" with contextual actions)
|
||||
- [x] WCAG AA reduced-motion media query (global animation/transition reset)
|
||||
- [x] Safe area inset utilities for notched devices
|
||||
- [x] Screen reader live region announcements (sr-only)
|
||||
|
||||
### Internationalization
|
||||
- [x] English language support
|
||||
- [x] French language support
|
||||
- [x] Japanese language support
|
||||
- [x] Spanish language support
|
||||
- [x] Italian language support
|
||||
- [x] German language support
|
||||
- [x] Dutch language support
|
||||
- [x] Portuguese language support
|
||||
- [x] Automatic browser language detection
|
||||
- [x] Language preference persistence
|
||||
|
||||
### Security & Accessibility
|
||||
- [x] External content blocked by default
|
||||
- [x] HTML sanitization with DOMPurify
|
||||
- [x] User control for loading external content
|
||||
- [x] Trusted senders list for automatic image loading
|
||||
- [x] Dark mode email readability (intelligent color transformation)
|
||||
- [x] WCAG 2.0 Level AA color contrast compliance
|
||||
- [x] Newsletter unsubscribe support (RFC 2369)
|
||||
- [x] XSS attack prevention with comprehensive validation
|
||||
- [x] CSP Report-Only headers with per-request nonce
|
||||
- [x] Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy)
|
||||
- [x] Reusable focus trap hook (Tab cycling, Escape handling, focus restore)
|
||||
- [x] WCAG AA prefers-reduced-motion support (global animation/transition reset)
|
||||
- [x] Safe area insets for notched mobile devices
|
||||
- [x] Screen reader sr-only live region for dynamic announcements
|
||||
|
||||
### Identity Management
|
||||
- [x] Multiple sender identities (name, email, signature)
|
||||
- [x] Sub-addressing support (user+tag@domain.com)
|
||||
- [x] Per-identity signatures
|
||||
- [x] Identity badges in email viewer and list
|
||||
- [x] Tag suggestions based on context
|
||||
- [x] Display name included in From header (recipients see name, not just email)
|
||||
- [x] Primary identity (matching login) selected by default in composer
|
||||
|
||||
### Address Book & Contacts
|
||||
- [x] Contact store with JMAP sync and local fallback
|
||||
- [x] Contact CRUD operations (create, read, update, delete)
|
||||
- [x] Contacts list view with search/filter
|
||||
- [x] Contact details view/edit form
|
||||
- [x] JMAP contacts sync (RFC 9553/9610 ContactCard/AddressBook)
|
||||
- [x] Email autocomplete from contacts
|
||||
- [x] Contacts integration in email composer (To/Cc/Bcc)
|
||||
- [x] Contact groups/lists management with JMAP members map
|
||||
- [x] vCard import/export (RFC 6350 parser/generator, duplicate detection)
|
||||
- [x] Bulk contact operations (multi-select, delete, group add, export)
|
||||
- [x] i18n support for contacts (all 8 languages)
|
||||
|
||||
### Vacation Responder
|
||||
- [x] JMAP VacationResponse singleton management
|
||||
- [x] Settings tab with date range and message configuration
|
||||
- [x] Sidebar indicator when vacation auto-reply is active
|
||||
- [x] i18n support (all 8 languages)
|
||||
|
||||
### Calendar Integration
|
||||
- [x] JMAP Calendar types (RFC 8984) and client methods
|
||||
- [x] Calendar capability detection (urn:ietf:params:jmap:calendars)
|
||||
- [x] Calendar store with Zustand (persist middleware)
|
||||
- [x] Month, week, day, and agenda views
|
||||
- [x] Event modal (create/edit/delete with recurrence, reminders)
|
||||
- [x] Mini-calendar sidebar with calendar visibility toggles
|
||||
- [x] Calendar settings (default view, week start, time format)
|
||||
- [x] Multi-day event spanning across all covered days
|
||||
- [x] Column-based overlap layout for concurrent events
|
||||
- [x] Locale-aware date formatting via next-intl
|
||||
- [x] First day of week and time format settings wired to views
|
||||
- [x] Push notification handling for calendar state changes
|
||||
- [x] Calendar page capability check (redirect if unsupported)
|
||||
- [x] Error handling with toast feedback on event CRUD
|
||||
- [x] Timezone auto-detection on event creation
|
||||
- [x] Input validation, color sanitization, focus trap
|
||||
- [x] ARIA grid roles and event card accessible labels
|
||||
- [x] Mobile touch targets (44px minimum)
|
||||
- [x] Calendar keyboard shortcuts (m/w/d/a views, t today, n new event)
|
||||
- [x] i18n support with ICU pluralization (all 8 languages)
|
||||
- [x] Drag-and-drop event rescheduling (week/day time snap, month date move)
|
||||
- [x] iCalendar (.ics) file import via CalendarEvent/parse with preview and bulk create
|
||||
- [x] Event notifications with client-side alert evaluation and toast display
|
||||
- [x] Notification sound, acknowledged alert persistence (localStorage), proactive 24h event fetch
|
||||
- [x] Configurable notification settings (enable/disable, sound toggle)
|
||||
- [x] Participant scheduling with iTIP invitations (organizer/attendee UI, RSVP buttons, contact autocomplete)
|
||||
- [x] Inline calendar invitation banner in email viewer (auto-detect .ics attachments, RSVP, import to calendar, cancellation display)
|
||||
- [x] Scheduling message support (sendSchedulingMessages flag for create/update/delete)
|
||||
- [x] Click-drag to create events (pointer-based time range selection, 15-min snap, visual overlay)
|
||||
- [x] Event resize by dragging bottom edge handle (15-min snap, optimistic JMAP update)
|
||||
- [x] Recurring event edit/delete scope dialog (this event / this and following / all events)
|
||||
- [x] Double-click quick event creation (inline title input, PT1H default)
|
||||
- [x] Event duplication button in modal (clones event +1 day, opens for editing)
|
||||
|
||||
### Email Filters
|
||||
- [x] JMAP Sieve Scripts (RFC 9661) with capability detection
|
||||
- [x] Visual rule builder (conditions: From/To/Cc/Subject/Header/Size/Body, actions: Move/Copy/Forward/Mark read/Star/Label/Discard/Reject/Keep/Stop)
|
||||
- [x] Raw Sieve script editor with syntax validation
|
||||
- [x] Sieve generator and parser with JSON metadata round-trip
|
||||
- [x] Filter store with CRUD, reorder, toggle, auto-save with rollback
|
||||
- [x] Opaque script detection with reset to visual builder option
|
||||
- [x] Focus trap accessibility in modals
|
||||
- [x] Toast validation feedback for empty rules
|
||||
- [x] Push notification handling for SieveScript state changes
|
||||
- [x] i18n support (all 8 languages)
|
||||
|
||||
### Email Templates
|
||||
- [x] Reusable email templates with local storage persistence
|
||||
- [x] Category organization (General, Business, Personal, Support, Follow-up, custom)
|
||||
- [x] Dynamic placeholder variables with auto-fill from composer context
|
||||
- [x] Template manager modal (create, edit, duplicate, delete)
|
||||
- [x] Template picker in composer toolbar with search and category filter
|
||||
- [x] Custom placeholder prompt on template insertion
|
||||
- [x] Settings tab for template management
|
||||
- [x] Keyboard shortcut (Ctrl+Shift+T to insert template)
|
||||
- [x] i18n support (all 8 languages)
|
||||
|
||||
### Email Display
|
||||
- [x] Proper email layout without horizontal scroll or clipping
|
||||
- [x] Blocked image container collapsing (no empty spaces in newsletters)
|
||||
|
||||
### Testing
|
||||
- [x] Unit tests for validation utilities (57 tests)
|
||||
- [x] Unit tests for email sanitization (27 tests)
|
||||
- [x] Unit tests for color transformation (40 tests)
|
||||
- [x] Unit tests for contact store (56 tests)
|
||||
- [x] Unit tests for JMAP contact client (41 tests)
|
||||
- [x] Unit tests for vCard parser (18 tests)
|
||||
- [x] Unit tests for thread utilities (20 tests)
|
||||
- [x] Unit tests for email headers (39 tests)
|
||||
- [x] Component tests (contacts, UI components — 41 tests)
|
||||
- [x] JMAP client method tests (identity: 20, contacts: 41)
|
||||
- [x] Unit tests for Sieve generator (50 tests)
|
||||
- [x] Unit tests for Sieve parser (14 tests)
|
||||
- [x] Unit tests for calendar alerts (36 tests)
|
||||
- [x] Unit tests for calendar notification store (8 tests)
|
||||
- [x] Unit tests for calendar invitation parsing (25 tests)
|
||||
- [x] Unit tests for calendar participants (26 tests)
|
||||
- [x] Unit tests for template utilities (48 tests)
|
||||
- [x] Unit tests for OAuth PKCE and discovery (14 tests)
|
||||
- [x] XSS attack vector testing
|
||||
- [x] Playwright E2E framework setup
|
||||
|
||||
### Deployment
|
||||
- [x] Runtime environment variables (Docker-friendly configuration)
|
||||
- [x] Health check endpoint
|
||||
- [x] Docker support (multi-stage build, docker-compose, standalone output)
|
||||
- [x] Structured server-side logger (text/JSON format, configurable level)
|
||||
- [x] Pre-built Docker image on [Docker Hub](https://hub.docker.com/r/rootfr/jmap-webmail) and [GHCR](https://ghcr.io/root-fr/jmap-webmail) with multi-arch support (amd64/arm64)
|
||||
- [x] GitHub Actions CI/CD for automated image publishing on releases
|
||||
- [x] CVE remediation: remove npm from production image, upgrade Alpine packages
|
||||
- [x] Server-side update check (logs newer version availability on startup)
|
||||
|
||||
## Planned Features
|
||||
|
||||
### Advanced Features
|
||||
- [ ] Free/busy queries (Principal/getAvailability)
|
||||
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
|
||||
- [ ] Email encryption (PGP/GPG)
|
||||
|
||||
### Performance Optimizations
|
||||
- [ ] Email content caching
|
||||
- [ ] Bundle size optimization
|
||||
- [ ] Service worker for offline support
|
||||
- [ ] Lazy loading for attachments
|
||||
|
||||
### Testing (Remaining)
|
||||
- [ ] E2E tests with real JMAP server
|
||||
- [ ] Accessibility testing
|
||||
- [ ] Performance testing
|
||||
|
||||
### Deployment
|
||||
- [ ] Production build optimizations
|
||||
- [ ] Monitoring and logging
|
||||
|
||||
### Security Enhancements
|
||||
- [ ] Rate limiting
|
||||
|
||||
## Known Issues
|
||||
|
||||
- [ ] Next.js workspace root warning (cosmetic)
|
||||
|
||||
## Contributing
|
||||
|
||||
Want to help implement a feature? Check out our [CONTRIBUTING.md](CONTRIBUTING.md) guide!
|
||||
@@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -13,7 +14,7 @@ function OAuthCallbackInner() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("login");
|
||||
const { loginWithOAuth } = useAuthStore();
|
||||
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,35 +33,75 @@ function OAuthCallbackInner() {
|
||||
}
|
||||
|
||||
const savedState = sessionStorage.getItem("oauth_state");
|
||||
if (!state || state !== savedState) {
|
||||
setError("invalid_state");
|
||||
return;
|
||||
}
|
||||
|
||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
||||
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
||||
if (savedState) {
|
||||
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
|
||||
if (!state || state !== savedState) {
|
||||
setError("invalid_state");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!codeVerifier || !serverUrl) {
|
||||
setError("missing_params");
|
||||
return;
|
||||
}
|
||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
||||
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
||||
const serverId = sessionStorage.getItem("oauth_server_id") || undefined;
|
||||
|
||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
||||
if (!codeVerifier || !serverUrl) {
|
||||
setError("missing_params");
|
||||
return;
|
||||
}
|
||||
|
||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
sessionStorage.removeItem("oauth_state");
|
||||
sessionStorage.removeItem("oauth_code_verifier");
|
||||
sessionStorage.removeItem("oauth_server_url");
|
||||
router.push(`/${params.locale}`);
|
||||
} else {
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
|
||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
sessionStorage.removeItem("oauth_state");
|
||||
sessionStorage.removeItem("oauth_code_verifier");
|
||||
sessionStorage.removeItem("oauth_server_url");
|
||||
sessionStorage.removeItem("oauth_server_id");
|
||||
sessionStorage.removeItem("oauth_add_account_mode");
|
||||
let redirectTo = `${prefix}/${params.locale}`;
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
if (saved) {
|
||||
sessionStorage.removeItem('redirect_after_login');
|
||||
redirectTo = saved;
|
||||
}
|
||||
} catch { /* sessionStorage may be unavailable */ }
|
||||
router.push(redirectTo);
|
||||
} else {
|
||||
setError("token_exchange_failed");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError("token_exchange_failed");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError("token_exchange_failed");
|
||||
});
|
||||
});
|
||||
} else if (state) {
|
||||
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||
loginWithServerSso(code, state)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
let redirectTo = `${ssoPrefix}/${params.locale}`;
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
if (saved) {
|
||||
sessionStorage.removeItem('redirect_after_login');
|
||||
redirectTo = saved;
|
||||
}
|
||||
} catch { /* sessionStorage may be unavailable */ }
|
||||
router.push(redirectTo);
|
||||
} else {
|
||||
setError("token_exchange_failed");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError("token_exchange_failed");
|
||||
});
|
||||
} else {
|
||||
setError("invalid_state");
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (error) {
|
||||
@@ -78,7 +119,7 @@ function OAuthCallbackInner() {
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/${params.locale}/login`)}
|
||||
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
|
||||
>
|
||||
{t("oauth_error.back_to_login")}
|
||||
</Button>
|
||||
|
||||
+818
-78
File diff suppressed because it is too large
Load Diff
+490
-159
@@ -1,27 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react";
|
||||
import { ArrowLeft, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { ContactList } from "@/components/contacts/contact-list";
|
||||
import { ContactDetail } from "@/components/contacts/contact-detail";
|
||||
import { ContactForm } from "@/components/contacts/contact-form";
|
||||
import { ContactGroupList } from "@/components/contacts/contact-group-list";
|
||||
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -31,23 +38,23 @@ type View =
|
||||
| "group-detail"
|
||||
| "group-create"
|
||||
| "group-edit"
|
||||
| "import"
|
||||
| "bulk-add-to-group";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("contacts");
|
||||
const { client, isAuthenticated } = useAuthStore();
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
contacts,
|
||||
addressBooks,
|
||||
selectedContactId,
|
||||
searchQuery,
|
||||
supportsSync,
|
||||
activeTab,
|
||||
selectedContactIds,
|
||||
setSelectedContact,
|
||||
setSearchQuery,
|
||||
setActiveTab,
|
||||
fetchContacts,
|
||||
createContact,
|
||||
updateContact,
|
||||
@@ -62,24 +69,65 @@ export default function ContactsPage() {
|
||||
removeMembersFromGroup,
|
||||
deleteGroup,
|
||||
toggleContactSelection,
|
||||
selectRangeContacts,
|
||||
selectAllContacts,
|
||||
clearSelection,
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
renameAddressBook,
|
||||
removeAddressBook,
|
||||
shareAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
const [view, setView] = useState<View>("list");
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Panel resize state - sidebar (categories)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
|
||||
});
|
||||
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
||||
const sidebarDragStartWidth = useRef(256);
|
||||
|
||||
// Panel resize state - contact list
|
||||
const [listWidth, setListWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
|
||||
});
|
||||
const [isListResizing, setIsListResizing] = useState(false);
|
||||
const listDragStartWidth = useRef(384);
|
||||
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.push("/login");
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && supportsSync && !hasFetched.current) {
|
||||
@@ -88,11 +136,110 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, supportsSync, fetchContacts]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh contacts via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client && supportsSync,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
await fetchContacts(client);
|
||||
},
|
||||
});
|
||||
|
||||
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
||||
const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
|
||||
|
||||
// Collect all unique keywords across contacts
|
||||
const allKeywords = useMemo(() => {
|
||||
const kws = new Set<string>();
|
||||
for (const contact of individuals) {
|
||||
if (!contact.keywords) continue;
|
||||
for (const [kw, active] of Object.entries(contact.keywords)) {
|
||||
if (active) kws.add(kw);
|
||||
}
|
||||
}
|
||||
return Array.from(kws).sort((a, b) => a.localeCompare(b));
|
||||
}, [individuals]);
|
||||
|
||||
// Contacts to display based on active category
|
||||
const displayedContacts = useMemo(() => {
|
||||
if (activeCategory === "all") return individuals;
|
||||
if (activeCategory === "uncategorized") {
|
||||
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
|
||||
}
|
||||
if ("addressBookId" in activeCategory) {
|
||||
const bookId = activeCategory.addressBookId;
|
||||
return individuals.filter(c => {
|
||||
if (!c.addressBookIds) return false;
|
||||
return c.addressBookIds[bookId] === true;
|
||||
});
|
||||
}
|
||||
if ("keyword" in activeCategory) {
|
||||
return individuals.filter(c => c.keywords?.[activeCategory.keyword]);
|
||||
}
|
||||
// Show members of the selected group
|
||||
return getGroupMembers(activeCategory.groupId);
|
||||
}, [activeCategory, individuals, getGroupMembers]);
|
||||
|
||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||
setActiveCategory(category);
|
||||
clearSelection();
|
||||
if (typeof category === "object" && "groupId" in category) {
|
||||
setSelectedGroupId(category.groupId);
|
||||
setView("group-detail");
|
||||
} else {
|
||||
setSelectedGroupId(null);
|
||||
}
|
||||
}, [clearSelection]);
|
||||
|
||||
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await moveContactToAddressBook(client, contactIds, addressBook);
|
||||
const msg = contactIds.length === 1
|
||||
? t("address_books.moved", { name: addressBook.name })
|
||||
: t("address_books.moved_plural", { count: contactIds.length, name: addressBook.name });
|
||||
toast.success(msg);
|
||||
} catch (error) {
|
||||
console.error('Failed to move contacts:', error);
|
||||
toast.error(t("address_books.move_failed"));
|
||||
}
|
||||
}, [client, moveContactToAddressBook, t]);
|
||||
|
||||
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
|
||||
if (!client && supportsSync) return;
|
||||
try {
|
||||
for (const contactId of contactIds) {
|
||||
const contact = contacts.find(c => c.id === contactId);
|
||||
if (!contact) continue;
|
||||
const existingKeywords = contact.keywords || {};
|
||||
if (existingKeywords[keyword]) continue; // already has this keyword
|
||||
const updatedKeywords = { ...existingKeywords, [keyword]: true };
|
||||
if (supportsSync && client) {
|
||||
await updateContact(client, contactId, { keywords: updatedKeywords });
|
||||
} else {
|
||||
updateLocalContact(contactId, { keywords: updatedKeywords });
|
||||
}
|
||||
}
|
||||
const msg = contactIds.length === 1
|
||||
? t("category_added", { name: keyword })
|
||||
: t("category_added_plural", { count: contactIds.length, name: keyword });
|
||||
toast.success(msg);
|
||||
} catch (error) {
|
||||
console.error('Failed to add contacts to category:', error);
|
||||
toast.error(t("toast.error_update"));
|
||||
}
|
||||
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||
return importContacts(
|
||||
supportsSync && client ? client : null,
|
||||
importedContacts
|
||||
);
|
||||
}, [supportsSync, client, importContacts]);
|
||||
|
||||
const handleSelectContact = (id: string) => {
|
||||
setSelectedContact(id);
|
||||
@@ -109,9 +256,7 @@ export default function ContactsPage() {
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
|
||||
const deleteContactById = useCallback(async (contactId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("delete_confirm"),
|
||||
@@ -122,31 +267,74 @@ export default function ContactsPage() {
|
||||
|
||||
try {
|
||||
if (supportsSync && client) {
|
||||
await deleteContact(client, selectedContact.id);
|
||||
await deleteContact(client, contactId);
|
||||
} else {
|
||||
deleteLocalContact(selectedContact.id);
|
||||
deleteLocalContact(contactId);
|
||||
}
|
||||
toast.success(t("toast.deleted"));
|
||||
setView("list");
|
||||
if (selectedContactId === contactId) setView("list");
|
||||
} catch (error) {
|
||||
console.error('Failed to delete contact:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
await deleteContactById(selectedContact.id);
|
||||
};
|
||||
|
||||
const handleEditContact = useCallback((id: string) => {
|
||||
setSelectedContact(id);
|
||||
setView("edit");
|
||||
}, [setSelectedContact]);
|
||||
|
||||
const handleDeleteContact = useCallback((contact: ContactCard) => {
|
||||
void deleteContactById(contact.id);
|
||||
}, [deleteContactById]);
|
||||
|
||||
const handleAddContactToGroup = useCallback((id: string) => {
|
||||
clearSelection();
|
||||
toggleContactSelection(id);
|
||||
if (groups.length === 0) {
|
||||
setView("group-create");
|
||||
return;
|
||||
}
|
||||
setView("bulk-add-to-group");
|
||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||
|
||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
||||
void _id; void _created; void _updated;
|
||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: data.addressBookIds || {},
|
||||
...data,
|
||||
};
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: {},
|
||||
...data,
|
||||
};
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
setView("list");
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
@@ -164,10 +352,9 @@ export default function ContactsPage() {
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
if (view === "group-create" || view === "group-edit") {
|
||||
setView(selectedGroup ? "group-detail" : "list");
|
||||
} else if (view === "import") {
|
||||
setView("list");
|
||||
} else if (view === "bulk-add-to-group") {
|
||||
setView("list");
|
||||
} else {
|
||||
@@ -175,8 +362,9 @@ export default function ContactsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectGroup = (id: string) => {
|
||||
const _handleSelectGroup = (id: string) => {
|
||||
setSelectedGroupId(id);
|
||||
setActiveCategory({ groupId: id });
|
||||
setView("group-detail");
|
||||
};
|
||||
|
||||
@@ -189,6 +377,35 @@ export default function ContactsPage() {
|
||||
setView("group-edit");
|
||||
};
|
||||
|
||||
const handleEditGroupFromSidebar = useCallback((groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setActiveCategory({ groupId });
|
||||
setView("group-edit");
|
||||
}, []);
|
||||
|
||||
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("groups.delete_confirm_title"),
|
||||
message: t("groups.delete_confirm"),
|
||||
confirmText: t("form.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteGroup(supportsSync && client ? client : null, groupId);
|
||||
toast.success(t("toast.deleted"));
|
||||
if (selectedGroupId === groupId) {
|
||||
setSelectedGroupId(null);
|
||||
setActiveCategory("all");
|
||||
setView("list");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete group:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
}, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
|
||||
|
||||
const handleDeleteGroup = async () => {
|
||||
if (!selectedGroup) return;
|
||||
|
||||
@@ -215,11 +432,10 @@ export default function ContactsPage() {
|
||||
const jmapClient = supportsSync && client ? client : null;
|
||||
if (view === "group-edit" && selectedGroup) {
|
||||
await updateGroup(jmapClient, selectedGroup.id, name);
|
||||
const currentMemberIds = selectedGroup.members
|
||||
? Object.keys(selectedGroup.members).filter(k => selectedGroup.members![k])
|
||||
: [];
|
||||
const toAdd = memberIds.filter(id => !currentMemberIds.includes(id));
|
||||
const toRemove = currentMemberIds.filter(id => !memberIds.includes(id));
|
||||
// Use resolved member contact IDs for diff, not raw urn:uuid: keys
|
||||
const currentIds = selectedGroupMembers.map(m => m.id);
|
||||
const toAdd = memberIds.filter(id => !currentIds.includes(id));
|
||||
const toRemove = currentIds.filter(id => !memberIds.includes(id));
|
||||
if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd);
|
||||
if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove);
|
||||
toast.success(t("toast.updated"));
|
||||
@@ -229,7 +445,7 @@ export default function ContactsPage() {
|
||||
toast.success(t("toast.created"));
|
||||
setView("list");
|
||||
}
|
||||
}, [view, selectedGroup, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
|
||||
}, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
|
||||
|
||||
const handleRemoveGroupMember = async (memberId: string) => {
|
||||
if (!selectedGroup) return;
|
||||
@@ -303,25 +519,20 @@ export default function ContactsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = useCallback(async (importedContacts: ContactCard[]) => {
|
||||
return importContacts(
|
||||
supportsSync && client ? client : null,
|
||||
importedContacts
|
||||
);
|
||||
}, [supportsSync, client, importContacts]);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
case "create":
|
||||
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
return (
|
||||
<ContactForm
|
||||
contact={selectedContact}
|
||||
addressBooks={addressBooks}
|
||||
allKeywords={allKeywords}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
@@ -336,9 +547,9 @@ export default function ContactsPage() {
|
||||
onEdit={handleEditGroup}
|
||||
onDelete={handleDeleteGroup}
|
||||
onRemoveMember={handleRemoveGroupMember}
|
||||
isMobile={isMobile}
|
||||
onSelectMember={(id) => {
|
||||
setSelectedContact(id);
|
||||
setActiveTab("all");
|
||||
setView("detail");
|
||||
}}
|
||||
/>
|
||||
@@ -365,15 +576,6 @@ export default function ContactsPage() {
|
||||
/>
|
||||
);
|
||||
|
||||
case "import":
|
||||
return (
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
onImport={handleImport}
|
||||
onClose={handleCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
case "bulk-add-to-group":
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -422,135 +624,264 @@ export default function ContactsPage() {
|
||||
contact={selectedContact}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAddToGroup={
|
||||
selectedContact
|
||||
? () => handleAddContactToGroup(selectedContact.id)
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={
|
||||
selectedContact
|
||||
? () => void handleDuplicateContact(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const showListPanel = !isMobile || view === "list";
|
||||
const showRightPanel = !isMobile || view !== "list";
|
||||
|
||||
const mobileBackToList = () => {
|
||||
setView("list");
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Navigation Rail - desktop only */}
|
||||
{!isMobile && (
|
||||
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
|
||||
<NavigationRail collapsed />
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-80 border-r border-border flex flex-col flex-shrink-0">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/")}
|
||||
className="justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("back_to_mail")}
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setView("import")}
|
||||
title={t("import.title")}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
if (contacts.length > 0) {
|
||||
exportContacts(contacts.filter(c => c.kind !== "group"));
|
||||
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
|
||||
}
|
||||
}}
|
||||
title={t("export.title")}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab("all")}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
activeTab === "all"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<BookUser className="w-4 h-4" />
|
||||
{t("tabs.all")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("groups")}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
activeTab === "groups"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
{t("tabs.groups")}
|
||||
{groups.length > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted">
|
||||
{groups.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "all" ? (
|
||||
<ContactList
|
||||
contacts={contacts}
|
||||
selectedContactId={selectedContactId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
onImport={() => setView("import")}
|
||||
supportsSync={supportsSync}
|
||||
className="flex-1"
|
||||
selectedContactIds={selectedContactIds}
|
||||
onToggleSelection={toggleContactSelection}
|
||||
onSelectAll={selectAllContacts}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkAddToGroup={handleBulkAddToGroup}
|
||||
onBulkExport={handleBulkExport}
|
||||
/>
|
||||
) : (
|
||||
<ContactGroupList
|
||||
groups={groups}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onSelectGroup={handleSelectGroup}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
searchQuery={searchQuery}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
{showListPanel && (
|
||||
<>
|
||||
{/* Panel 1: Categories sidebar */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border flex flex-col flex-shrink-0",
|
||||
!isSidebarResizing && "transition-[width] duration-300"
|
||||
)}
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<ContactsSidebar
|
||||
groups={groups}
|
||||
individuals={individuals}
|
||||
addressBooks={addressBooks}
|
||||
activeCategory={activeCategory}
|
||||
onSelectCategory={handleSelectCategory}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onCreateContact={handleCreateNew}
|
||||
onImport={() => setShowImportDialog(true)}
|
||||
onEditGroup={handleEditGroupFromSidebar}
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||
onCreateContactInBook={(book) => {
|
||||
setDefaultBookIdForCreate(book.id);
|
||||
handleCreateNew();
|
||||
}}
|
||||
onDeleteAddressBook={client ? async (book) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("address_books.delete"),
|
||||
message: t("address_books.confirm_delete", { name: book.name }),
|
||||
variant: "destructive",
|
||||
confirmText: t("address_books.delete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await removeAddressBook(client, book);
|
||||
toast.success(t("address_books.deleted"));
|
||||
} catch {
|
||||
toast.error(t("address_books.delete_failed"));
|
||||
}
|
||||
} : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsSidebarResizing(false);
|
||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
{/* Panel 2: Contact list */}
|
||||
<div
|
||||
data-tour="contacts-list"
|
||||
className={cn(
|
||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
||||
isMobile ? "w-full" : "",
|
||||
!isListResizing && !isMobile && "transition-[width] duration-300"
|
||||
)}
|
||||
style={!isMobile ? { width: `${listWidth}px` } : undefined}
|
||||
>
|
||||
<ContactList
|
||||
contacts={displayedContacts}
|
||||
selectedContactId={selectedContactId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
className="flex-1"
|
||||
selectedContactIds={selectedContactIds}
|
||||
onToggleSelection={toggleContactSelection}
|
||||
onSelectRangeContacts={selectRangeContacts}
|
||||
onSelectAll={selectAllContacts}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkAddToGroup={handleBulkAddToGroup}
|
||||
onBulkExport={handleBulkExport}
|
||||
onEditContact={handleEditContact}
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={handleAddContactToGroup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { listDragStartWidth.current = listWidth; setIsListResizing(true); }}
|
||||
onResize={(delta) => setListWidth(Math.max(220, Math.min(500, listDragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsListResizing(false);
|
||||
localStorage.setItem("contacts-list-width", String(listWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Panel 3: Detail / Form */}
|
||||
{showRightPanel && (
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{isMobile && (
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={mobileBackToList}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("back_to_contacts")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
{renamingKeyword !== null && (
|
||||
<RenameDialog
|
||||
currentName={renamingKeyword}
|
||||
title={t("rename_category")}
|
||||
label={t("category_name_label")}
|
||||
onCancel={() => setRenamingKeyword(null)}
|
||||
onConfirm={async (newName) => {
|
||||
try {
|
||||
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
|
||||
toast.success(t("category_renamed"));
|
||||
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
|
||||
setActiveCategory({ keyword: newName.trim() });
|
||||
}
|
||||
setRenamingKeyword(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename category:", err);
|
||||
toast.error(t("category_rename_failed"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renamingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName={renamingAddressBook.name}
|
||||
title={t("address_books.rename")}
|
||||
label={t("address_books.name_label")}
|
||||
onCancel={() => setRenamingAddressBook(null)}
|
||||
onConfirm={async (newName) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await renameAddressBook(client, renamingAddressBook, newName);
|
||||
toast.success(t("address_books.renamed"));
|
||||
setRenamingAddressBook(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename address book:", err);
|
||||
toast.error(t("address_books.rename_failed"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showImportDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
onImport={handleImportContacts}
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sharingAddressBookId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
|
||||
if (!book) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="addressBook"
|
||||
collectionName={book.name}
|
||||
shareWith={book.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||
}}
|
||||
onClose={() => setSharingAddressBookId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { FileBrowser } from "@/components/files/file-browser";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
export default function FilesPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("files");
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
currentPath,
|
||||
resources,
|
||||
isLoading,
|
||||
error,
|
||||
supportsFiles,
|
||||
selectedResources,
|
||||
uploadProgress,
|
||||
clipboard,
|
||||
initClient,
|
||||
checkSupport,
|
||||
navigate,
|
||||
navigateByPath,
|
||||
refresh,
|
||||
createDirectory,
|
||||
uploadFile: _uploadFile,
|
||||
uploadFiles,
|
||||
uploadFolder,
|
||||
deleteResource,
|
||||
deleteResources,
|
||||
renameResource,
|
||||
downloadResource,
|
||||
getImageUrl,
|
||||
getFileContent,
|
||||
createTextFile,
|
||||
duplicateResource,
|
||||
downloadResources,
|
||||
moveToFolder,
|
||||
moveToParent,
|
||||
cutResources,
|
||||
copyResources,
|
||||
pasteResources,
|
||||
selectResource,
|
||||
toggleSelect,
|
||||
selectAll,
|
||||
clearSelection,
|
||||
setSelection,
|
||||
listPath,
|
||||
listByParentId,
|
||||
favorites,
|
||||
recentFiles,
|
||||
toggleFavorite,
|
||||
addRecentFile,
|
||||
cancelUpload,
|
||||
undoLastAction,
|
||||
lastAction,
|
||||
} = useFileStore();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
// Sync folderLayout when settings change
|
||||
useEffect(() => {
|
||||
const reload = () => setFolderLayout(loadFilesSettings().folderLayout);
|
||||
const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reload(); };
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener("files-settings-changed", reload);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener("files-settings-changed", reload);
|
||||
};
|
||||
}, []);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewFile, setPreviewFile] = useState<string | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [detailName, setDetailName] = useState<string | null>(null);
|
||||
|
||||
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
|
||||
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Initialize JMAP files client
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
initClient(client);
|
||||
}
|
||||
}, [isAuthenticated, client, initClient]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh files via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client && supportsFiles === true,
|
||||
onRefresh: async () => {
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
// Check support and load root after client is initialized
|
||||
const storeClient = useFileStore(s => s.client);
|
||||
useEffect(() => {
|
||||
if (storeClient && supportsFiles === null) {
|
||||
checkSupport().then((supported) => {
|
||||
if (supported) {
|
||||
navigate(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
||||
|
||||
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
||||
if (resourceId !== undefined) {
|
||||
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
|
||||
navigate(resourceId, path.split('/').pop() || '');
|
||||
} else {
|
||||
// Path-based navigation (breadcrumbs, favorites, recent files)
|
||||
navigateByPath(path);
|
||||
}
|
||||
}, [navigate, navigateByPath]);
|
||||
|
||||
const handleCreateFolder = useCallback(async (name: string) => {
|
||||
try {
|
||||
await createDirectory(name);
|
||||
toast.success(t("create_folder_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to create folder:", err);
|
||||
toast.error(t("create_folder_error"));
|
||||
}
|
||||
}, [createDirectory, t]);
|
||||
|
||||
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
|
||||
|
||||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||
files = files.filter(f => f.size <= maxSizeUpload);
|
||||
if (oversized.length > 0) {
|
||||
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||||
}
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
await uploadFiles(files);
|
||||
toast.success(t("upload_success", { count: files.length }));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload files:", err);
|
||||
toast.error(t("upload_error"));
|
||||
}
|
||||
}, [uploadFiles, t, maxSizeUpload]);
|
||||
|
||||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||
files = files.filter(f => f.size <= maxSizeUpload);
|
||||
if (oversized.length > 0) {
|
||||
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||||
}
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
await uploadFolder(files);
|
||||
toast.success(t("upload_success", { count: files.length }));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload folder:", err);
|
||||
toast.error(t("upload_error"));
|
||||
}
|
||||
}, [uploadFolder, t, maxSizeUpload]);
|
||||
|
||||
const handleDelete = useCallback(async (name: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("delete_confirm_message", { name }),
|
||||
confirmText: t("delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteResource(name);
|
||||
toast.success(t("delete_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
toast.error(t("delete_error"));
|
||||
}
|
||||
}, [deleteResource, confirmDialog, t]);
|
||||
|
||||
const handleBatchDelete = useCallback(async (names: string[]) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("batch_delete_confirm_message", { count: names.length }),
|
||||
confirmText: t("delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteResources(names);
|
||||
toast.success(t("batch_delete_success", { count: names.length }));
|
||||
} catch (err) {
|
||||
console.error("Failed to batch delete:", err);
|
||||
toast.error(t("delete_error"));
|
||||
}
|
||||
}, [deleteResources, confirmDialog, t]);
|
||||
|
||||
const handleUndo = useCallback(async () => {
|
||||
try {
|
||||
await undoLastAction();
|
||||
toast.success(t("undo_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to undo:", err);
|
||||
toast.error(t("undo_error"));
|
||||
}
|
||||
}, [undoLastAction, t]);
|
||||
|
||||
const handleRename = useCallback(async (oldName: string, newName: string) => {
|
||||
try {
|
||||
await renameResource(oldName, newName);
|
||||
toast.success(t("rename_success"), {
|
||||
action: { label: t("undo"), onClick: handleUndo },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to rename:", err);
|
||||
toast.error(t("rename_error"));
|
||||
}
|
||||
}, [renameResource, t, handleUndo]);
|
||||
|
||||
const findResourceId = useCallback((name: string) => {
|
||||
const r = resources.find(res => res.name === name);
|
||||
return r?.id || name;
|
||||
}, [resources]);
|
||||
|
||||
const handleDownload = useCallback(async (name: string) => {
|
||||
try {
|
||||
await downloadResource(name);
|
||||
addRecentFile(name, findResourceId(name));
|
||||
} catch (err) {
|
||||
console.error("Failed to download:", err);
|
||||
toast.error(t("download_error"));
|
||||
}
|
||||
}, [downloadResource, addRecentFile, findResourceId, t]);
|
||||
|
||||
const handleBatchDownload = useCallback(async (names: string[]) => {
|
||||
try {
|
||||
await downloadResources(names);
|
||||
} catch (err) {
|
||||
console.error("Failed to batch download:", err);
|
||||
toast.error(t("download_error"));
|
||||
}
|
||||
}, [downloadResources, t]);
|
||||
|
||||
const handleCreateTextFile = useCallback(async (name: string) => {
|
||||
try {
|
||||
await createTextFile(name);
|
||||
toast.success(t("create_file_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to create file:", err);
|
||||
toast.error(t("create_file_error"));
|
||||
}
|
||||
}, [createTextFile, t]);
|
||||
|
||||
const handleDuplicate = useCallback(async (name: string) => {
|
||||
try {
|
||||
await duplicateResource(name);
|
||||
toast.success(t("duplicate_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to duplicate:", err);
|
||||
toast.error(t("duplicate_error"));
|
||||
}
|
||||
}, [duplicateResource, t]);
|
||||
|
||||
const handleMoveToFolder = useCallback(async (names: string[], targetFolder: string) => {
|
||||
try {
|
||||
await moveToFolder(names, targetFolder);
|
||||
toast.success(t("move_success", { count: names.length }), {
|
||||
action: { label: t("undo"), onClick: handleUndo },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to move:", err);
|
||||
toast.error(t("move_error"));
|
||||
}
|
||||
}, [moveToFolder, t, handleUndo]);
|
||||
|
||||
const handleMoveToParent = useCallback(async (names: string[]) => {
|
||||
try {
|
||||
await moveToParent(names);
|
||||
toast.success(t("move_success", { count: names.length }), {
|
||||
action: { label: t("undo"), onClick: handleUndo },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to move:", err);
|
||||
toast.error(t("move_error"));
|
||||
}
|
||||
}, [moveToParent, t, handleUndo]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
try {
|
||||
await pasteResources();
|
||||
toast.success(t("paste_success"), {
|
||||
action: lastAction ? { label: t("undo"), onClick: handleUndo } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to paste:", err);
|
||||
toast.error(t("paste_error"));
|
||||
}
|
||||
}, [pasteResources, t, lastAction, handleUndo]);
|
||||
|
||||
const handlePreviewImage = useCallback((name: string) => {
|
||||
setPreviewImage(name);
|
||||
addRecentFile(name, findResourceId(name));
|
||||
}, [addRecentFile, findResourceId]);
|
||||
|
||||
const handlePreviewFile = useCallback((name: string) => {
|
||||
setPreviewFile(name);
|
||||
addRecentFile(name, findResourceId(name));
|
||||
}, [addRecentFile, findResourceId]);
|
||||
|
||||
const handleShowDetails = useCallback((name: string) => {
|
||||
setDetailName(name);
|
||||
setShowDetails(true);
|
||||
}, []);
|
||||
|
||||
const handleToggleDetails = useCallback(() => {
|
||||
setShowDetails(v => !v);
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh bg-background overflow-hidden">
|
||||
{!isMobile && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{folderLayout !== "sidebar" && (
|
||||
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/")}
|
||||
className="justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("title")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!filesEnabled ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="max-w-lg text-center space-y-3 px-4">
|
||||
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||
<p className="text-sm font-medium">{t("disabled_title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : supportsFiles === false ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
|
||||
</div>
|
||||
<FileBrowser
|
||||
currentPath={currentPath}
|
||||
resources={resources}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
selectedResources={selectedResources}
|
||||
uploadProgress={uploadProgress}
|
||||
clipboard={clipboard}
|
||||
onNavigate={handleNavigate}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onUploadFiles={handleUploadFiles}
|
||||
onUploadFolder={handleUploadFolder}
|
||||
onCancelUpload={cancelUpload}
|
||||
onDelete={handleDelete}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onRename={handleRename}
|
||||
onDownload={handleDownload}
|
||||
onBatchDownload={handleBatchDownload}
|
||||
onRefresh={refresh}
|
||||
onSelectResource={selectResource}
|
||||
onToggleSelect={toggleSelect}
|
||||
onSelectAll={selectAll}
|
||||
onClearSelection={clearSelection}
|
||||
onSetSelection={setSelection}
|
||||
onCut={cutResources}
|
||||
onCopy={copyResources}
|
||||
onPaste={handlePaste}
|
||||
onMoveToFolder={handleMoveToFolder}
|
||||
onMoveToParent={handleMoveToParent}
|
||||
onPreviewImage={handlePreviewImage}
|
||||
onPreviewFile={handlePreviewFile}
|
||||
onShowDetails={handleShowDetails}
|
||||
onCreateTextFile={handleCreateTextFile}
|
||||
onDuplicate={handleDuplicate}
|
||||
getImageUrl={getImageUrl}
|
||||
listPath={listPath}
|
||||
listByParentId={listByParentId}
|
||||
favorites={favorites}
|
||||
recentFiles={recentFiles}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
showDetails={showDetails}
|
||||
onToggleDetails={handleToggleDetails}
|
||||
detailResource={detailResource}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image preview modal */}
|
||||
{previewImage && (
|
||||
<ImagePreviewModal
|
||||
name={previewImage}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
onDownload={handleDownload}
|
||||
getImageUrl={getImageUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* File preview modal (text, PDF, audio, video, markdown) */}
|
||||
{previewFile && (
|
||||
<FilePreviewModal
|
||||
name={previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
onDownload={() => handleDownload(previewFile)}
|
||||
getFileContent={() => getFileContent(previewFile)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+10
-1
@@ -2,6 +2,9 @@ import { notFound } from "next/navigation";
|
||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -26,7 +29,13 @@ export default async function LocaleLayout({
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
<CalendarAlertProvider>
|
||||
{children}
|
||||
<RateLimitToastProvider>
|
||||
<EmbeddedBridgeProvider>
|
||||
<TourProvider>
|
||||
{children}
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
</RateLimitToastProvider>
|
||||
</CalendarAlertProvider>
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
|
||||
+990
-247
File diff suppressed because it is too large
Load Diff
+1654
-209
File diff suppressed because it is too large
Load Diff
+906
-61
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface Props {
|
||||
value: JmapServerEntry[];
|
||||
source?: string;
|
||||
onChange: (next: JmapServerEntry[]) => void;
|
||||
onRevert: () => void;
|
||||
}
|
||||
|
||||
interface RowDraft {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
domains: string;
|
||||
oauthClientId: string;
|
||||
oauthIssuerUrl: string;
|
||||
oauthClientSecret: string;
|
||||
oauthExpanded: boolean;
|
||||
}
|
||||
|
||||
function entryToDraft(e: JmapServerEntry): RowDraft {
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
url: e.url,
|
||||
domains: (e.domains ?? []).join(', '),
|
||||
oauthClientId: e.oauth?.clientId ?? '',
|
||||
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
|
||||
oauthClientSecret: e.oauth?.clientSecret ?? '',
|
||||
oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
|
||||
};
|
||||
}
|
||||
|
||||
function draftToEntry(d: RowDraft): JmapServerEntry | null {
|
||||
const id = d.id.trim();
|
||||
const url = d.url.trim().replace(/\/+$/, '');
|
||||
if (!id || !url) return null;
|
||||
const domains = d.domains
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const clientId = d.oauthClientId.trim();
|
||||
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
|
||||
const clientSecret = d.oauthClientSecret;
|
||||
const oauth = clientId || issuerUrl || clientSecret
|
||||
? {
|
||||
...(clientId ? { clientId } : {}),
|
||||
...(issuerUrl ? { issuerUrl } : {}),
|
||||
...(clientSecret ? { clientSecret } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
label: d.label.trim() || id,
|
||||
url,
|
||||
...(domains.length > 0 ? { domains } : {}),
|
||||
...(oauth ? { oauth } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyDraft(): RowDraft {
|
||||
return {
|
||||
id: '',
|
||||
label: '',
|
||||
url: '',
|
||||
domains: '',
|
||||
oauthClientId: '',
|
||||
oauthIssuerUrl: '',
|
||||
oauthClientSecret: '',
|
||||
oauthExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
||||
// initial load). Skip when drafts already represent the same array to avoid
|
||||
// clobbering in-progress edits.
|
||||
setDrafts((prev) => {
|
||||
if (prev.length === value.length) {
|
||||
const same = prev.every((d, i) => {
|
||||
const e = value[i];
|
||||
return d.id === e.id && d.url === e.url && d.label === e.label;
|
||||
});
|
||||
if (same) return prev;
|
||||
}
|
||||
return value.map(entryToDraft);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
function commit(next: RowDraft[]) {
|
||||
setDrafts(next);
|
||||
const entries: JmapServerEntry[] = [];
|
||||
for (const d of next) {
|
||||
const e = draftToEntry(d);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
onChange(entries);
|
||||
}
|
||||
|
||||
function update(idx: number, patch: Partial<RowDraft>) {
|
||||
commit(drafts.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
commit(drafts.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function add() {
|
||||
setDrafts((prev) => [...prev, emptyDraft()]);
|
||||
// Don't commit yet - new row needs id+url before it counts.
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
const duplicateIdx = new Set<number>();
|
||||
drafts.forEach((d, i) => {
|
||||
const id = d.id.trim();
|
||||
if (!id) return;
|
||||
if (ids.has(id)) duplicateIdx.add(i);
|
||||
ids.add(id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">Servers</span>
|
||||
{source && source !== 'default' && (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{source === 'admin' && (
|
||||
<button
|
||||
onClick={onRevert}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Revert to default"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={add}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic">No servers configured.</div>
|
||||
)}
|
||||
|
||||
{drafts.map((d, i) => {
|
||||
const isDuplicate = duplicateIdx.has(i);
|
||||
return (
|
||||
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.id}
|
||||
onChange={(e) => update(i, { id: e.target.value })}
|
||||
placeholder="main"
|
||||
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`}
|
||||
/>
|
||||
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder="Main server"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-5">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={d.url}
|
||||
onChange={(e) => update(i, { url: e.target.value })}
|
||||
placeholder="https://mail.example.com"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(i)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
title="Remove server"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
||||
Email domains (comma-separated, used for auto-pick)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.domains}
|
||||
onChange={(e) => update(i, { domains: e.target.value })}
|
||||
placeholder="example.com, example.org"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => update(i, { oauthExpanded: !d.oauthExpanded })}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
{d.oauthExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
Per-server OAuth (optional, overrides global)
|
||||
</button>
|
||||
{d.oauthExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.oauthClientId}
|
||||
onChange={(e) => update(i, { oauthClientId: e.target.value })}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={d.oauthIssuerUrl}
|
||||
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
|
||||
placeholder="https://auth.example.com"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
value={d.oauthClientSecret}
|
||||
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
export function AuthTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: unknown) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): unknown {
|
||||
if (key in edits) return edits[key];
|
||||
return config[key]?.value;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Authentication settings saved.' });
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
await fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
const [setupRunning, setSetupRunning] = useState(false);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [setupOrigin, setSetupOrigin] = useState('');
|
||||
const [setupIssuer, setSetupIssuer] = useState('');
|
||||
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
||||
|
||||
function openSetupDialog() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const origin = window.location.origin;
|
||||
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
|
||||
setSetupOrigin(origin);
|
||||
setSetupIssuer(jmapUrl || origin);
|
||||
setSetupOauthOnly(currentValue('oauthOnly') === true);
|
||||
setSetupOpen(true);
|
||||
}
|
||||
|
||||
async function handleAutoSetup() {
|
||||
setSetupRunning(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/oauth/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
||||
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
||||
oauthOnly: setupOauthOnly,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
|
||||
});
|
||||
setEdits({});
|
||||
setSetupOpen(false);
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
||||
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
|
||||
} finally {
|
||||
setSetupRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
||||
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
||||
Requires your Stalwart account to have admin permissions.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openSetupDialog}
|
||||
disabled={setupRunning}
|
||||
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="oauth-setup-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||
Webmail origin
|
||||
</label>
|
||||
<input
|
||||
id="setup-origin"
|
||||
type="url"
|
||||
value={setupOrigin}
|
||||
onChange={(e) => setSetupOrigin(e.target.value)}
|
||||
disabled={setupRunning}
|
||||
placeholder="https://webmail.example.com"
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||
</p>
|
||||
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||
Stalwart issuer URL
|
||||
</label>
|
||||
<input
|
||||
id="setup-issuer"
|
||||
type="url"
|
||||
value={setupIssuer}
|
||||
onChange={(e) => setSetupIssuer(e.target.value)}
|
||||
disabled={setupRunning}
|
||||
placeholder="https://mail.example.com"
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||
</p>
|
||||
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupOauthOnly}
|
||||
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-input"
|
||||
disabled={setupRunning}
|
||||
/>
|
||||
Also enable “OAuth only” (hide password login)
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||
<button
|
||||
onClick={() => setSetupOpen(false)}
|
||||
disabled={setupRunning}
|
||||
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoSetup}
|
||||
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section title="OAuth / OpenID Connect">
|
||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
</Section>
|
||||
|
||||
<Section title="Single Sign-On">
|
||||
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
|
||||
<Section title="Session & Security">
|
||||
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
|
||||
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
|
||||
label: string; description?: string; configKey: string; value: string; source?: string;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
|
||||
className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
const IMAGE_FIELDS = [
|
||||
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
|
||||
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
];
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
];
|
||||
|
||||
export function BrandingTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): string {
|
||||
if (key in edits) return edits[key] as string;
|
||||
return (config[key]?.value as string) ?? '';
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleUpload(slot: string, file: File) {
|
||||
setUploading(slot);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('slot', slot);
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[slot]: { value: data.url, source: 'admin' },
|
||||
}));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
setUploading(null);
|
||||
}
|
||||
|
||||
async function handleDeleteUpload(slot: string) {
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface AdminStatus {
|
||||
enabled: boolean;
|
||||
authenticated: boolean;
|
||||
lastLogin: string | null;
|
||||
passwordChangedAt: string | null;
|
||||
}
|
||||
|
||||
interface ConfigData {
|
||||
appName?: string;
|
||||
jmapServerUrl?: string;
|
||||
settingsSyncEnabled?: boolean;
|
||||
stalwartFeaturesEnabled?: boolean;
|
||||
oauthEnabled?: boolean;
|
||||
devMode?: boolean;
|
||||
}
|
||||
|
||||
export function DashboardTab() {
|
||||
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [themeCount, setThemeCount] = useState(0);
|
||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
async function fetchDashboardData() {
|
||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
|
||||
apiFetch('/api/admin/auth'),
|
||||
apiFetch('/api/admin/audit?limit=10'),
|
||||
apiFetch('/api/config'),
|
||||
apiFetch('/api/admin/config'),
|
||||
apiFetch('/api/admin/plugins').catch(() => null),
|
||||
apiFetch('/api/admin/themes').catch(() => null),
|
||||
apiFetch('/api/admin/policy').catch(() => null),
|
||||
apiFetch('/api/admin/telemetry').catch(() => null),
|
||||
]);
|
||||
|
||||
if (statusRes.ok) setStatus(await statusRes.json());
|
||||
if (auditRes.ok) {
|
||||
const data = await auditRes.json();
|
||||
setRecentActivity(data.entries || []);
|
||||
}
|
||||
let configData: ConfigData | null = null;
|
||||
if (configRes.ok) {
|
||||
configData = await configRes.json();
|
||||
setConfig(configData);
|
||||
}
|
||||
|
||||
if (pluginRes?.ok) {
|
||||
const plugins = await pluginRes.json();
|
||||
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
|
||||
}
|
||||
if (themeRes?.ok) {
|
||||
const themes = await themeRes.json();
|
||||
setThemeCount(Array.isArray(themes) ? themes.length : 0);
|
||||
}
|
||||
if (policyRes?.ok) {
|
||||
const policy = await policyRes.json();
|
||||
const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
|
||||
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||
}
|
||||
if (telemetryRes?.ok) {
|
||||
const telemetry = await telemetryRes.json();
|
||||
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
|
||||
setAccountCounts(telemetry.accountCounts);
|
||||
}
|
||||
}
|
||||
|
||||
if (configData?.jmapServerUrl) {
|
||||
try {
|
||||
const jmapRes = await apiFetch('/api/config');
|
||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||
} catch {
|
||||
setJmapHealth('error');
|
||||
}
|
||||
}
|
||||
|
||||
const w: string[] = [];
|
||||
if (adminConfigRes.ok) {
|
||||
const sources = await adminConfigRes.json();
|
||||
setConfigSources(sources);
|
||||
const sessionSecret = sources?.sessionSecret;
|
||||
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
||||
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||
}
|
||||
const adminPassword = sources?.adminPassword;
|
||||
if (adminPassword?.value && adminPassword.source === 'env') {
|
||||
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
|
||||
}
|
||||
}
|
||||
setWarnings(w);
|
||||
}
|
||||
|
||||
const jmapUrl = config?.jmapServerUrl || '-';
|
||||
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
{warnings.map((msg, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-warning">{msg}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{status && !status.lastLogin && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-warning">First login detected</p>
|
||||
<p className="text-sm text-warning/80 mt-0.5">
|
||||
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Server" description="Application and connection details">
|
||||
<SettingItem label="Application">
|
||||
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Connection">
|
||||
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
|
||||
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
|
||||
}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
|
||||
}`} />
|
||||
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last Login">
|
||||
<span className="text-sm text-foreground">
|
||||
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Features" description="Enabled integrations and modules">
|
||||
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
|
||||
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
|
||||
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
<SettingItem label="OAuth" description="OAuth authentication provider">
|
||||
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
|
||||
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
||||
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
||||
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
||||
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||
<SettingItem label="Plugins">
|
||||
<span className="text-sm text-foreground">{pluginCount}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Themes">
|
||||
<span className="text-sm text-foreground">{themeCount}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Policy Rules">
|
||||
<span className="text-sm text-foreground">{policyRuleCount}</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Recent Activity" description="Latest administrative actions">
|
||||
{recentActivity.length === 0 ? (
|
||||
<div className="py-4 text-sm text-muted-foreground">
|
||||
No activity recorded yet
|
||||
</div>
|
||||
) : (
|
||||
recentActivity.map((entry, i) => (
|
||||
<SettingItem
|
||||
key={i}
|
||||
label={entry.action}
|
||||
description={formatDetail(entry.detail) || undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{entry.ip}</span>
|
||||
<span>{new Date(entry.ts).toLocaleString()}</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
))
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): string {
|
||||
if (!detail || Object.keys(detail).length === 0) return '';
|
||||
if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
|
||||
if (detail.reason) return String(detail.reason);
|
||||
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
|
||||
return JSON.stringify(detail).slice(0, 80);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export function LogsTab() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const limit = 50;
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (actionFilter) params.set('action', actionFilter);
|
||||
|
||||
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setTotal(data.total || 0);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page, actionFilter]);
|
||||
|
||||
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||
className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
<option value="admin.login">Login</option>
|
||||
<option value="admin.logout">Logout</option>
|
||||
<option value="admin.login_failed">Login Failed</option>
|
||||
<option value="admin.login_blocked">Login Blocked</option>
|
||||
<option value="admin.change-password">Password Change</option>
|
||||
<option value="config.update">Config Update</option>
|
||||
<option value="config.revert">Config Revert</option>
|
||||
<option value="policy.update">Policy Update</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-2">
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{new Date(entry.ts).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-foreground break-words">
|
||||
{formatDetail(entry.detail)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
|
||||
</tr>
|
||||
) : entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<tr key={i} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(entry.ts).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
||||
{formatDetail(entry.detail)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): string {
|
||||
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||
if (detail.reason) return String(detail.reason);
|
||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||
if (detail.changes && Array.isArray(detail.changes)) {
|
||||
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
|
||||
}
|
||||
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
||||
return JSON.stringify(detail).slice(0, 100);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface Extension {
|
||||
slug: string;
|
||||
name: string;
|
||||
type: 'plugin' | 'theme';
|
||||
pluginType: string | null;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
tags: string[];
|
||||
totalDownloads: number;
|
||||
featured: boolean;
|
||||
minAppVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
installed: boolean;
|
||||
iconUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
author: {
|
||||
displayName: string;
|
||||
githubLogin: string;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
data: Extension[];
|
||||
meta: {
|
||||
page: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
type TypeFilter = 'all' | 'plugin' | 'theme';
|
||||
|
||||
export function MarketplaceTab() {
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [perPage] = useState(12);
|
||||
const [installing, setInstalling] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchExtensions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('q', query);
|
||||
if (typeFilter !== 'all') params.set('type', typeFilter);
|
||||
params.set('page', String(page));
|
||||
params.set('perPage', String(perPage));
|
||||
params.set('sort', 'newest');
|
||||
|
||||
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || 'Failed to connect to extension directory');
|
||||
setExtensions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: SearchResult = await res.json();
|
||||
setExtensions(data.data || []);
|
||||
setTotal(data.meta?.total || 0);
|
||||
} catch {
|
||||
setError('Failed to connect to extension directory. Make sure it is running.');
|
||||
setExtensions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query, typeFilter, page, perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExtensions();
|
||||
}, [fetchExtensions]);
|
||||
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setQuery(searchInput);
|
||||
setPage(1);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
async function handleInstall(ext: Extension) {
|
||||
setInstalling(ext.slug);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/marketplace', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: ext.slug,
|
||||
version: ext.latestVersion || '1.0.0',
|
||||
type: ext.type,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
||||
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Browse and install plugins and themes from the BulwarkMail extension directory
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search extensions..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
||||
{(['all', 'plugin', 'theme'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTypeFilter(t); setPage(1); }}
|
||||
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
|
||||
typeFilter === t
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-border rounded-lg p-12 text-center">
|
||||
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Start the extension directory server on the configured port
|
||||
</p>
|
||||
<button
|
||||
onClick={fetchExtensions}
|
||||
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && extensions.length === 0 && (
|
||||
<div className="border border-border rounded-lg p-12 text-center">
|
||||
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No extensions found</p>
|
||||
{query && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Try a different search term
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && extensions.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{total} extension{total !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{extensions.map((ext) => (
|
||||
<ExtensionCard
|
||||
key={ext.slug}
|
||||
extension={ext}
|
||||
installing={installing === ext.slug}
|
||||
onInstall={() => handleInstall(ext)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionCard({
|
||||
extension,
|
||||
installing,
|
||||
onInstall,
|
||||
}: {
|
||||
extension: Extension;
|
||||
installing: boolean;
|
||||
onInstall: () => void;
|
||||
}) {
|
||||
const isPlugin = extension.type === 'plugin';
|
||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||
|
||||
return (
|
||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
{extension.bannerUrl && (
|
||||
<Link href={previewHref} className="block focus:outline-none">
|
||||
<img
|
||||
src={extension.bannerUrl}
|
||||
alt=""
|
||||
className="block h-24 w-full object-cover border-b border-border"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{extension.iconUrl ? (
|
||||
<img
|
||||
src={extension.iconUrl}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isPlugin ? (
|
||||
<Puzzle className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<SwatchBook className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
|
||||
{extension.name}
|
||||
</span>
|
||||
{extension.featured && (
|
||||
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
isPlugin
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||
}`}>
|
||||
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
|
||||
</span>
|
||||
{extension.author && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
by {extension.author.displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
|
||||
{extension.description}
|
||||
</p>
|
||||
|
||||
{extension.tags && extension.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{extension.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
{extension.totalDownloads.toLocaleString()}
|
||||
</span>
|
||||
{extension.permissions && extension.permissions.length > 0 && (
|
||||
<span title={extension.permissions.join(', ')}>
|
||||
{extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||
<Eye className="w-3 h-3" />
|
||||
Preview
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="px-4 pb-4 -mt-1">
|
||||
{extension.installed ? (
|
||||
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Installed
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||
disabled={installing}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-3 h-3" />
|
||||
)}
|
||||
Quick install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigField {
|
||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
placeholder?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
interface PluginConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
author: string;
|
||||
type: string;
|
||||
permissions: string[];
|
||||
enabled: boolean;
|
||||
configSchema?: Record<string, ConfigField>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
||||
const [config, setConfig] = useState<PluginConfig>({});
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function fetchData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [pluginsRes, configRes] = await Promise.all([
|
||||
apiFetch('/api/admin/plugins'),
|
||||
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
if (pluginsRes.ok) {
|
||||
const plugins: PluginInfo[] = await pluginsRes.json();
|
||||
setPlugin(plugins.find(p => p.id === pluginId) || null);
|
||||
}
|
||||
|
||||
if (configRes.ok) {
|
||||
setConfig(await configRes.json());
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
return () => { cancelled = true; };
|
||||
}, [pluginId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!plugin?.configSchema) return;
|
||||
const initial: Record<string, string> = {};
|
||||
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||
const stored = config[key];
|
||||
if (stored !== undefined && stored !== null) {
|
||||
initial[key] = String(stored);
|
||||
} else if (field.default !== undefined) {
|
||||
initial[key] = String(field.default);
|
||||
} else {
|
||||
initial[key] = '';
|
||||
}
|
||||
}
|
||||
setFormValues(initial);
|
||||
}, [plugin, config]);
|
||||
|
||||
async function handleSaveAll() {
|
||||
if (!plugin?.configSchema) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||
if (field.required && !formValues[key]?.trim()) {
|
||||
setMessage({ type: 'error', text: `"${field.label}" is required` });
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let hasError = false;
|
||||
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||
const newVal = formValues[key] ?? '';
|
||||
const oldVal = config[key] !== undefined ? String(config[key]) : '';
|
||||
|
||||
if (newVal === oldVal) continue;
|
||||
if (field.type === 'secret' && !newVal && config[key]) continue;
|
||||
|
||||
let value: unknown = newVal;
|
||||
if (field.type === 'boolean') value = newVal === 'true';
|
||||
else if (field.type === 'number') value = Number(newVal);
|
||||
|
||||
if (!newVal && !field.required) {
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
} else {
|
||||
hasError = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConfig(prev => ({ ...prev, [key]: value }));
|
||||
} else {
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(hasError
|
||||
? { type: 'error', text: 'Some settings failed to save' }
|
||||
: { type: 'success', text: 'Configuration saved' }
|
||||
);
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save configuration' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!plugin) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||
</button>
|
||||
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const schema = plugin.configSchema;
|
||||
const hasSchema = schema && Object.keys(schema).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Back to Plugins"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Puzzle className="w-5 h-5" />
|
||||
{plugin.name} Configuration
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
v{plugin.version} by {plugin.author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSchema ? (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Settings</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-5">
|
||||
{Object.entries(schema).map(([key, field]) => (
|
||||
<div key={key}>
|
||||
<label className="text-sm font-medium text-foreground block mb-1">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' ? (
|
||||
<select
|
||||
value={formValues[key] ?? String(field.default ?? 'false')}
|
||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
) : field.type === 'select' && field.options ? (
|
||||
<select
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">- Select -</option>
|
||||
{field.options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'secret' ? (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={revealSecrets[key] ? 'text' : 'password'}
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
||||
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
|
||||
>
|
||||
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
placeholder={field.placeholder || ''}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleSaveAll}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { PluginConfigPanel } from './plugin-config-panel';
|
||||
|
||||
interface PluginEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
forceEnabled?: boolean;
|
||||
permissions: string[];
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function PluginsTab() {
|
||||
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [policyDirty, setPolicyDirty] = useState(false);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
const [configuringId, setConfiguringId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
||||
|
||||
async function fetchPolicy() {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function togglePluginsEnabled() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function togglePluginsUploadEnabled() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleRequirePluginApproval() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSavePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
|
||||
setPolicyDirty(false);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlugins() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins');
|
||||
if (res.ok) setPlugins(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
||||
await fetchPlugins();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Upload failed' });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||
setMessage(null);
|
||||
const body: Record<string, unknown> = { id, forceEnabled };
|
||||
if (forceEnabled) body.enabled = true;
|
||||
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
|
||||
setPolicy(prev => {
|
||||
const current = prev.forceEnabledPlugins || [];
|
||||
return {
|
||||
...prev,
|
||||
forceEnabledPlugins: forceEnabled
|
||||
? [...current.filter(pid => pid !== id), id]
|
||||
: current.filter(pid => pid !== id),
|
||||
};
|
||||
});
|
||||
setPolicyDirty(true);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async function forceEnableAll() {
|
||||
setMessage(null);
|
||||
const disabled = plugins.filter(p => !p.enabled);
|
||||
if (disabled.length === 0) {
|
||||
setMessage({ type: 'success', text: 'All plugins are already enabled' });
|
||||
return;
|
||||
}
|
||||
let failed = 0;
|
||||
for (const p of disabled) {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||
});
|
||||
if (!res.ok) failed++;
|
||||
}
|
||||
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
|
||||
if (failed === 0) {
|
||||
await fetchPlugins();
|
||||
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
|
||||
} else {
|
||||
await fetchPlugins();
|
||||
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
|
||||
}
|
||||
}
|
||||
|
||||
async function forceDisableAll() {
|
||||
setMessage(null);
|
||||
const enabled = plugins.filter(p => p.enabled);
|
||||
if (enabled.length === 0) {
|
||||
setMessage({ type: 'success', text: 'All plugins are already disabled' });
|
||||
return;
|
||||
}
|
||||
let failed = 0;
|
||||
for (const p of enabled) {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||
});
|
||||
if (!res.ok) failed++;
|
||||
}
|
||||
if (failed === 0) {
|
||||
await fetchPlugins();
|
||||
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
|
||||
} else {
|
||||
await fetchPlugins();
|
||||
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlugin(id: string, name: string) {
|
||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (configuringId) {
|
||||
return <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{policyDirty && (
|
||||
<button
|
||||
onClick={handleSavePolicy}
|
||||
disabled={savingPolicy}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Policy
|
||||
</button>
|
||||
)}
|
||||
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Upload Plugin
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Plugins Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
|
||||
</div>
|
||||
<button onClick={togglePluginsEnabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">User Plugin Uploads</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
|
||||
</div>
|
||||
<button onClick={togglePluginsUploadEnabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Require Admin Approval</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
||||
</div>
|
||||
<button onClick={toggleRequirePluginApproval}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plugins.length > 0 && (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={forceEnableAll}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
Enable All
|
||||
</button>
|
||||
<button
|
||||
onClick={forceDisableAll}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<PowerOff className="w-3.5 h-3.5" />
|
||||
Disable All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
|
||||
</div>
|
||||
{plugins.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No plugins installed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{plugins.map(plugin => (
|
||||
<div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
|
||||
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{plugin.forceEnabled && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" /> Forced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()}
|
||||
</div>
|
||||
{plugin.permissions.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||
<span className="text-xs text-warning">
|
||||
Permissions: {plugin.permissions.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfiguringId(plugin.id)}
|
||||
title="Configure"
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable' : 'Enable'}
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||
title="Remove"
|
||||
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, Lock } from 'lucide-react';
|
||||
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||
|
||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
|
||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
|
||||
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
|
||||
];
|
||||
|
||||
export function PolicyTab() {
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => { fetchPolicy(); }, []);
|
||||
|
||||
async function fetchPolicy() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFeature(key: keyof FeatureGates) {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, [key]: !prev.features[key] },
|
||||
}));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleLocked(settingKey: string) {
|
||||
setPolicy(prev => {
|
||||
const existing = prev.restrictions[settingKey] || {};
|
||||
const newRestrictions = { ...prev.restrictions };
|
||||
if (existing.locked) {
|
||||
delete newRestrictions[settingKey];
|
||||
} else {
|
||||
newRestrictions[settingKey] = { ...existing, locked: true };
|
||||
}
|
||||
return { ...prev, restrictions: newRestrictions };
|
||||
});
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleHidden(settingKey: string) {
|
||||
setPolicy(prev => {
|
||||
const existing = prev.restrictions[settingKey] || {};
|
||||
const newRestrictions = { ...prev.restrictions };
|
||||
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
|
||||
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
|
||||
delete newRestrictions[settingKey];
|
||||
}
|
||||
return { ...prev, restrictions: newRestrictions };
|
||||
});
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
|
||||
setDirty(false);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save policy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
|
||||
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
|
||||
.map(key => {
|
||||
const meta = FEATURE_GATE_LABELS[key];
|
||||
if (!meta) return null;
|
||||
const { label, description } = meta;
|
||||
const enabled = policy.features[key];
|
||||
return (
|
||||
<div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleFeature(key)}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.map(category => (
|
||||
<div key={category} className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{category}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||||
const restriction = policy.restrictions[setting.key] || {};
|
||||
return (
|
||||
<div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{setting.label}</span>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||||
className="rounded border-input" />
|
||||
<Lock className="w-3 h-3" /> Lock
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
||||
className="rounded border-input" />
|
||||
Hide
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { JmapServersSection } from './_jmap-servers-section';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
export function SettingsTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) {
|
||||
setConfig(await res.json());
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: unknown) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): unknown {
|
||||
if (key in edits) return edits[key];
|
||||
return config[key]?.value;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
setMessage({ type: 'success', text: `${key} reverted to default` });
|
||||
}
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection title="General">
|
||||
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="JMAP Servers (multi-server)">
|
||||
<ToggleSetting
|
||||
label="Auto-pick server by email domain"
|
||||
description="When users type their email, automatically select the matching server from the list below."
|
||||
configKey="jmapServerAutoPickByDomain"
|
||||
value={currentValue('jmapServerAutoPickByDomain') as boolean}
|
||||
source={config.jmapServerAutoPickByDomain?.source}
|
||||
onChange={handleChange}
|
||||
onRevert={handleRevert}
|
||||
/>
|
||||
<JmapServersSection
|
||||
value={(currentValue('jmapServers') as JmapServerEntry[]) ?? []}
|
||||
source={config.jmapServers?.source}
|
||||
onChange={(next) => handleChange('jmapServers', next)}
|
||||
onRevert={() => handleRevert('jmapServers')}
|
||||
/>
|
||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Logging">
|
||||
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Settings Sync">
|
||||
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
|
||||
label: string; configKey: string; value: string; source?: string;
|
||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{label}</label>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(configKey, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(configKey, e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface TelemetryStatus {
|
||||
consent: 'pending' | 'on' | 'off';
|
||||
consentSource: 'env' | 'file';
|
||||
endpoint: string;
|
||||
defaultEndpoint: string;
|
||||
consentedAt: string | null;
|
||||
lastSentAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
payloadPreview: Record<string, unknown>;
|
||||
accountCounts: { total: number; active7d: number };
|
||||
}
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const d = Date.now() - new Date(iso).getTime();
|
||||
if (d < 0) return new Date(iso).toLocaleString();
|
||||
const m = Math.floor(d / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m} min ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 48) return `${h} hours ago`;
|
||||
const days = Math.floor(h / 24);
|
||||
return `${days} days ago`;
|
||||
}
|
||||
|
||||
export function TelemetryTab() {
|
||||
const [status, setStatus] = useState<TelemetryStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [endpointDraft, setEndpointDraft] = useState('');
|
||||
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry');
|
||||
if (!r.ok) throw new Error('failed to load');
|
||||
const data = (await r.json()) as TelemetryStatus;
|
||||
setStatus(data);
|
||||
setEndpointDraft(data.endpoint);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
async function setConsent(consent: 'on' | 'off'): Promise<void> {
|
||||
setBusy('consent');
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set-consent', consent }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
alert(j.error ?? 'failed');
|
||||
}
|
||||
await refresh();
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
async function saveEndpoint(): Promise<void> {
|
||||
setBusy('endpoint');
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
alert(j.error ?? 'failed');
|
||||
}
|
||||
await refresh();
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
async function sendNow(): Promise<void> {
|
||||
setBusy('send');
|
||||
setSendResult(null);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'send-now' }),
|
||||
});
|
||||
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
|
||||
setSendResult({
|
||||
ok: !!j.ok,
|
||||
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
|
||||
});
|
||||
await refresh();
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
if (loading || !status) {
|
||||
return (
|
||||
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const envOverridden = status.consentSource === 'env';
|
||||
const isOn = status.consent === 'on';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
||||
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
|
||||
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
||||
<a
|
||||
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline inline-flex items-center gap-1"
|
||||
>
|
||||
Full schema and policy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Status</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
||||
{status.consent === 'off' && 'Heartbeats are off.'}
|
||||
{envOverridden && (
|
||||
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'consent' || envOverridden || isOn}
|
||||
onClick={() => void setConsent('on')}
|
||||
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
|
||||
onClick={() => void setConsent('off')}
|
||||
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
|
||||
<dt className="text-muted-foreground">Last sent</dt>
|
||||
<dd>{timeAgo(status.lastSentAt)}</dd>
|
||||
<dt className="text-muted-foreground">Next scheduled</dt>
|
||||
<dd>{timeAgo(status.nextScheduledAt)}</dd>
|
||||
<dt className="text-muted-foreground">Consented at</dt>
|
||||
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-2">
|
||||
<div className="font-medium">Account activity</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unique accounts that have logged in over the last 90 days. Identities are stored as a
|
||||
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
|
||||
heartbeat as bucketed ranges.
|
||||
</p>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
|
||||
<dt className="text-muted-foreground">Total (90d)</dt>
|
||||
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
|
||||
<dt className="text-muted-foreground">Active (7d)</dt>
|
||||
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-3">
|
||||
<div className="font-medium">Endpoint</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
|
||||
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={endpointDraft}
|
||||
onChange={(e) => setEndpointDraft(e.target.value)}
|
||||
placeholder={status.defaultEndpoint}
|
||||
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
||||
onClick={() => void saveEndpoint()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
|
||||
>
|
||||
<Save className="h-4 w-4" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Payload preview</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Exactly what the next heartbeat would send from this install, right now.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'send' || !isOn}
|
||||
onClick={() => void sendNow()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
||||
>
|
||||
<Send className="h-4 w-4" /> Send now
|
||||
</button>
|
||||
</div>
|
||||
{sendResult && (
|
||||
<div
|
||||
className={`text-sm flex items-center gap-2 ${
|
||||
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
{sendResult.msg}
|
||||
</div>
|
||||
)}
|
||||
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||
{JSON.stringify(status.payloadPreview, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const BUILTIN_THEME_OPTIONS = [
|
||||
{ id: 'builtin-nord', name: 'Nord' },
|
||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||
];
|
||||
|
||||
interface ThemeEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
variants: string[];
|
||||
enabled: boolean;
|
||||
forceEnabled?: boolean;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function ThemesTab() {
|
||||
const [themes, setThemes] = useState<ThemeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [policyDirty, setPolicyDirty] = useState(false);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
|
||||
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
|
||||
|
||||
async function fetchPolicy() {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy({
|
||||
...data,
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function toggleThemesEnabled() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleUserThemeUploads() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleBuiltinTheme(themeId: string) {
|
||||
setPolicy(prev => {
|
||||
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
|
||||
const isDisabled = disabled.includes(themeId);
|
||||
return {
|
||||
...prev,
|
||||
themePolicy: {
|
||||
...DEFAULT_THEME_POLICY,
|
||||
...prev.themePolicy,
|
||||
disabledBuiltinThemes: isDisabled
|
||||
? disabled.filter((id: string) => id !== themeId)
|
||||
: [...disabled, themeId],
|
||||
},
|
||||
};
|
||||
});
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleAdminTheme(themeId: string) {
|
||||
setPolicy(prev => {
|
||||
const disabled = prev.themePolicy?.disabledThemes || [];
|
||||
const isDisabled = disabled.includes(themeId);
|
||||
return {
|
||||
...prev,
|
||||
themePolicy: {
|
||||
...DEFAULT_THEME_POLICY,
|
||||
...prev.themePolicy,
|
||||
disabledThemes: isDisabled
|
||||
? disabled.filter((id: string) => id !== themeId)
|
||||
: [...disabled, themeId],
|
||||
},
|
||||
};
|
||||
});
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function setDefaultTheme(themeId: string | null) {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
themePolicy: {
|
||||
...DEFAULT_THEME_POLICY,
|
||||
...prev.themePolicy,
|
||||
defaultThemeId: themeId,
|
||||
},
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSavePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
|
||||
setPolicyDirty(false);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchThemes() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/themes');
|
||||
if (res.ok) setThemes(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
|
||||
await fetchThemes();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Upload failed' });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTheme(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||
setMessage(null);
|
||||
const body: Record<string, unknown> = { id, forceEnabled };
|
||||
if (forceEnabled) body.enabled = true;
|
||||
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
|
||||
setPolicy(prev => {
|
||||
const current = prev.forceEnabledThemes || [];
|
||||
return {
|
||||
...prev,
|
||||
forceEnabledThemes: forceEnabled
|
||||
? [...current.filter(tid => tid !== id), id]
|
||||
: current.filter(tid => tid !== id),
|
||||
};
|
||||
});
|
||||
setPolicyDirty(true);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async function forceEnableAll() {
|
||||
setMessage(null);
|
||||
const disabled = themes.filter(t => !t.enabled);
|
||||
if (disabled.length === 0) {
|
||||
setMessage({ type: 'success', text: 'All themes are already enabled' });
|
||||
return;
|
||||
}
|
||||
let failed = 0;
|
||||
for (const t of disabled) {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||
});
|
||||
if (!res.ok) failed++;
|
||||
}
|
||||
if (failed === 0) {
|
||||
await fetchThemes();
|
||||
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
|
||||
} else {
|
||||
await fetchThemes();
|
||||
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
|
||||
}
|
||||
}
|
||||
|
||||
async function forceDisableAll() {
|
||||
setMessage(null);
|
||||
const enabled = themes.filter(t => t.enabled);
|
||||
if (enabled.length === 0) {
|
||||
setMessage({ type: 'success', text: 'All themes are already disabled' });
|
||||
return;
|
||||
}
|
||||
let failed = 0;
|
||||
for (const t of enabled) {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||
});
|
||||
if (!res.ok) failed++;
|
||||
}
|
||||
if (failed === 0) {
|
||||
await fetchThemes();
|
||||
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
|
||||
} else {
|
||||
await fetchThemes();
|
||||
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTheme(id: string, name: string) {
|
||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setThemes(prev => prev.filter(t => t.id !== id));
|
||||
setMessage({ type: 'success', text: `Theme "${name}" removed` });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const themesEnabled = policy.features.themesEnabled ?? true;
|
||||
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{policyDirty && (
|
||||
<button
|
||||
onClick={handleSavePolicy}
|
||||
disabled={savingPolicy}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Policy
|
||||
</button>
|
||||
)}
|
||||
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Upload Theme
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Themes Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
|
||||
</div>
|
||||
<button onClick={toggleThemesEnabled}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">User Theme Uploads</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
|
||||
</div>
|
||||
<button onClick={toggleUserThemeUploads}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{themes.length > 0 && (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={forceEnableAll}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
Enable All
|
||||
</button>
|
||||
<button
|
||||
onClick={forceDisableAll}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<PowerOff className="w-3.5 h-3.5" />
|
||||
Disable All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Default Theme</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
|
||||
</div>
|
||||
<select
|
||||
value={policy.themePolicy?.defaultThemeId || ''}
|
||||
onChange={(e) => setDefaultTheme(e.target.value || null)}
|
||||
className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="">System Default</option>
|
||||
<optgroup label="Built-in">
|
||||
{BUILTIN_THEME_OPTIONS
|
||||
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
|
||||
.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{themes.length > 0 && (
|
||||
<optgroup label="Admin-deployed">
|
||||
{themes
|
||||
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
|
||||
.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{BUILTIN_THEME_OPTIONS.map(theme => {
|
||||
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
|
||||
return (
|
||||
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-foreground">{theme.name}</span>
|
||||
<button onClick={() => toggleBuiltinTheme(theme.id)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{themes.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{themes.map(theme => {
|
||||
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
|
||||
return (
|
||||
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-foreground">{theme.name}</span>
|
||||
<button onClick={() => toggleAdminTheme(theme.id)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
|
||||
</div>
|
||||
{themes.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No themes installed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{themes.map(theme => (
|
||||
<div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{theme.name}</span>
|
||||
<span className="text-xs text-muted-foreground">v{theme.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{theme.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{theme.forceEnabled && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" /> Forced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleForceEnabled(theme.id, !theme.forceEnabled)}
|
||||
title={theme.forceEnabled ? 'Remove force-enable (users can deactivate)' : 'Force enable (users cannot deactivate)'}
|
||||
className={`p-2 rounded-md transition-colors ${theme.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
{theme.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleTheme(theme.id, !theme.enabled)}
|
||||
title={theme.enabled ? 'Disable' : 'Enable'}
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTheme(theme.id, theme.name)}
|
||||
title="Remove"
|
||||
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
|
||||
|
||||
interface VersionAdminStatus {
|
||||
current: string;
|
||||
build: string;
|
||||
endpoint: string;
|
||||
defaultEndpoint: string;
|
||||
disabledByEnv: boolean;
|
||||
lastCheckedAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
status: UpdateStatus | null;
|
||||
}
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const d = Date.now() - new Date(iso).getTime();
|
||||
if (d < 0) return new Date(iso).toLocaleString();
|
||||
const m = Math.floor(d / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m} min ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 48) return `${h} hours ago`;
|
||||
return `${Math.floor(h / 24)} days ago`;
|
||||
}
|
||||
|
||||
function severityChip(severity: UpdateSeverity) {
|
||||
switch (severity) {
|
||||
case 'security':
|
||||
return {
|
||||
label: 'Security update',
|
||||
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
|
||||
Icon: ShieldAlert,
|
||||
};
|
||||
case 'deprecated':
|
||||
return {
|
||||
label: 'Deprecated',
|
||||
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
|
||||
Icon: ShieldAlert,
|
||||
};
|
||||
case 'normal':
|
||||
return {
|
||||
label: 'Update available',
|
||||
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
case 'unknown':
|
||||
return {
|
||||
label: 'Unknown',
|
||||
className: 'bg-muted text-muted-foreground border-border',
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
case 'none':
|
||||
default:
|
||||
return {
|
||||
label: 'Up to date',
|
||||
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
|
||||
Icon: CheckCircle2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function VersionTab() {
|
||||
const [data, setData] = useState<VersionAdminStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/version');
|
||||
if (!r.ok) throw new Error('failed to load');
|
||||
setData((await r.json()) as VersionAdminStatus);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
async function checkNow(): Promise<void> {
|
||||
setChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/version', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'check-now' }),
|
||||
});
|
||||
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
|
||||
setCheckResult({
|
||||
ok: !!j.ok,
|
||||
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
|
||||
});
|
||||
await refresh();
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = data.status;
|
||||
const chip = severityChip(status?.severity ?? 'none');
|
||||
const ChipIcon = chip.Icon;
|
||||
const releaseUrl = status?.url ?? null;
|
||||
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Hourly check against the Bulwark version server. Severity is decided server-side and
|
||||
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={checking}
|
||||
onClick={() => void checkNow()}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Check now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{checkResult && (
|
||||
<div
|
||||
className={`text-sm rounded-md px-3 py-2 ${
|
||||
checkResult.ok
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{checkResult.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Status">
|
||||
<SettingItem label="Severity">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
|
||||
>
|
||||
<ChipIcon className="h-3 w-3" />
|
||||
{chip.label}
|
||||
</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
|
||||
<span className="text-sm font-mono text-foreground">{data.current}</span>
|
||||
</SettingItem>
|
||||
{newer && (
|
||||
<SettingItem label="Latest release">
|
||||
{releaseUrl ? (
|
||||
<a
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
|
||||
>
|
||||
{newer} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-mono text-foreground">{newer}</span>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
{status?.advisory && (
|
||||
<SettingItem label="Advisory">
|
||||
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
|
||||
<SettingItem label="Last checked">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last success">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Next scheduled">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
|
||||
</SettingItem>
|
||||
{status?.checkedAt && (
|
||||
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
|
||||
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Source">
|
||||
<SettingItem
|
||||
label="Endpoint"
|
||||
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
|
||||
>
|
||||
<a
|
||||
href={data.endpoint}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
|
||||
>
|
||||
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
</SettingItem>
|
||||
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
|
||||
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
|
||||
{data.disabledByEnv ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=auth');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=branding');
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('New password must be at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess(true);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setTimeout(() => router.push('/admin'), 2000);
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || 'Failed to change password.');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Current Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
{success && (
|
||||
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAdminTabStore, type AdminTabId } from '@/stores/admin-tab-store';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Palette,
|
||||
Shield,
|
||||
Scale,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Activity,
|
||||
Package,
|
||||
Mail,
|
||||
Calendar,
|
||||
BookUser,
|
||||
HardDrive,
|
||||
Store,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||
// matches the regular settings page pattern, fixes the dev-mode "Rendering…"
|
||||
// hang we saw with both /admin/<segment> routes and ?tab= search params.
|
||||
const NAV_GROUPS: ReadonlyArray<{
|
||||
label: string;
|
||||
items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>;
|
||||
}> = [
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
items: [
|
||||
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Extensions',
|
||||
items: [
|
||||
{ tab: 'plugins', label: 'Plugins', icon: Puzzle },
|
||||
{ tab: 'themes', label: 'Themes', icon: SwatchBook },
|
||||
{ tab: 'marketplace', label: 'Marketplace', icon: Store },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ tab: 'version', label: 'Version', icon: Package },
|
||||
{ tab: 'telemetry', label: 'Telemetry', icon: Activity },
|
||||
{ tab: 'logs', label: 'Audit Log', icon: ScrollText },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const storeActiveTab = useAdminTabStore((s) => s.activeTab);
|
||||
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||
// Highlight the active tab only on /admin itself - on dynamic routes
|
||||
// (e.g. /admin/plugins/[id]) no tab is "current".
|
||||
const activeTab = pathname === '/admin' ? storeActiveTab : null;
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
|
||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
|
||||
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
|
||||
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
|
||||
|
||||
useEffect(() => {
|
||||
setMobileNavOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileNavOpen) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [mobileNavOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === '/admin/login') return;
|
||||
let cancelled = false;
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const jmapHeaders = getActiveAccountSlotHeaders();
|
||||
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
|
||||
const stalwartAdmin = data.stalwartAdmin === true;
|
||||
setIsStalwartAdmin(stalwartAdmin);
|
||||
|
||||
// If neither password-based admin nor Stalwart admin, redirect away
|
||||
if (!data.enabled && !stalwartAdmin) {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.authenticated) {
|
||||
setAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If Stalwart admin but not yet authenticated, auto-login
|
||||
if (stalwartAdmin) {
|
||||
const loginRes = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (loginRes.ok) {
|
||||
setAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
const body = await loginRes.json().catch(() => ({}));
|
||||
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
|
||||
setAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace('/admin/login');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
|
||||
setAuthenticated(false);
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth();
|
||||
return () => { cancelled = true; };
|
||||
}, [pathname, router]);
|
||||
|
||||
async function handleLogout() {
|
||||
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||
router.replace('/admin/login');
|
||||
}
|
||||
|
||||
// Don't gate the login page
|
||||
if (pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{NAV_GROUPS.map((group, groupIndex) => (
|
||||
<div key={group.label}>
|
||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map(({ tab, label, icon: Icon }) => {
|
||||
const active = activeTab === tab;
|
||||
const showDot = tab === 'version' && hasUpdate;
|
||||
const handleClick = () => {
|
||||
setActiveTab(tab);
|
||||
// From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
|
||||
// we still need a real navigation back to /admin so the page renders.
|
||||
if (pathname !== '/admin') router.push('/admin');
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Icon className={cn(
|
||||
'w-4 h-4',
|
||||
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{showDot && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
|
||||
active ? 'ring-accent' : 'ring-background',
|
||||
updateImportant ? 'bg-red-500' : 'bg-amber-500',
|
||||
)}
|
||||
aria-label={updateImportant ? 'Important update available' : 'Update available'}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||
{!isStalwartAdmin && (
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
pathname === '/admin/change-password'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<KeyRound className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
Change Password
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Slim webmail nav rail (desktop only) */}
|
||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||
) : (
|
||||
<div className="w-7 h-7 mb-2" />
|
||||
)}
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/calendar"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/contacts"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/files"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Admin Sidebar (desktop only) */}
|
||||
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||
</div>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{mobileNavOpen && (
|
||||
<div
|
||||
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<aside
|
||||
className={cn(
|
||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
aria-label="Admin navigation"
|
||||
aria-hidden={!mobileNavOpen}
|
||||
>
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 overflow-x-hidden">
|
||||
{/* Mobile header */}
|
||||
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
|
||||
{authError ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p className="font-medium">Admin authentication failed</p>
|
||||
<p className="mt-1 text-destructive/80">{authError}</p>
|
||||
</div>
|
||||
) : authenticated === null ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
|
||||
Loading admin panel…
|
||||
</div>
|
||||
) : authenticated ? (
|
||||
children
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile bottom nav (main webmail nav) */}
|
||||
<nav
|
||||
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
|
||||
</a>
|
||||
<a
|
||||
href="/calendar"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
|
||||
</a>
|
||||
<a
|
||||
href="/contacts"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
||||
</a>
|
||||
<a
|
||||
href="/files"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||
</a>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
|
||||
title="Admin"
|
||||
aria-current="page"
|
||||
>
|
||||
<div className="relative">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Login failed');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/admin');
|
||||
} catch {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
|
||||
) : (
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring"
|
||||
placeholder="Enter admin password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 shadow-sm"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=logs');
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Loader2,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Star,
|
||||
Trash2,
|
||||
Check,
|
||||
Settings as SettingsIcon,
|
||||
ExternalLink,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
FileCode,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface PreviewData {
|
||||
extension: {
|
||||
slug: string;
|
||||
name: string;
|
||||
type: 'plugin' | 'theme';
|
||||
pluginType: string | null;
|
||||
description: string;
|
||||
longDescription: string | null;
|
||||
tags: string[];
|
||||
permissions: string[];
|
||||
totalDownloads: number;
|
||||
featured: boolean;
|
||||
githubRepo: string | null;
|
||||
license: string | null;
|
||||
minAppVersion: string | null;
|
||||
iconUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
author: {
|
||||
displayName: string;
|
||||
githubLogin: string;
|
||||
avatarUrl: string | null;
|
||||
verified?: boolean;
|
||||
} | null;
|
||||
latestVersion: string | null;
|
||||
versions: Array<{
|
||||
version: string;
|
||||
changelog: string | null;
|
||||
bundleSize: number;
|
||||
minAppVersion: string | null;
|
||||
publishedAt: string | null;
|
||||
permissions: string[];
|
||||
}>;
|
||||
screenshots: Array<{ url: string; altText: string | null }>;
|
||||
themePreviews: Array<{
|
||||
variant: 'light' | 'dark';
|
||||
previewPath: string;
|
||||
colors: Record<string, string> | null;
|
||||
}>;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
bundle: {
|
||||
manifest: Record<string, unknown> | null;
|
||||
source: { name: string; content: string; truncated: boolean } | null;
|
||||
size: number;
|
||||
error: string | null;
|
||||
};
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
const RISKY_PERMISSIONS = new Set([
|
||||
'mail:write',
|
||||
'mail:delete',
|
||||
'storage:write',
|
||||
'network',
|
||||
'admin',
|
||||
]);
|
||||
|
||||
export default function MarketplacePreviewPage() {
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
|
||||
const [data, setData] = useState<PreviewData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [uninstalling, setUninstalling] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [showManifest, setShowManifest] = useState(false);
|
||||
|
||||
const fetchPreview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setError(body.error || 'Failed to load preview');
|
||||
return;
|
||||
}
|
||||
setData(await res.json());
|
||||
} catch {
|
||||
setError('Failed to connect to extension directory');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => { fetchPreview(); }, [fetchPreview]);
|
||||
|
||||
async function handleInstall() {
|
||||
if (!data) return;
|
||||
setInstalling(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/marketplace', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: data.extension.slug,
|
||||
version: data.extension.latestVersion || '1.0.0',
|
||||
type: data.extension.type,
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
|
||||
setData(prev => prev ? { ...prev, installed: true } : prev);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: body.error || 'Installation failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUninstall() {
|
||||
if (!data) return;
|
||||
if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return;
|
||||
|
||||
setUninstalling(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const endpoint = data.extension.type === 'theme'
|
||||
? '/api/admin/themes'
|
||||
: '/api/admin/plugins';
|
||||
const res = await apiFetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: data.extension.slug }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
|
||||
setData(prev => prev ? { ...prev, installed: false } : prev);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
|
||||
} finally {
|
||||
setUninstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
href="/admin/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||
</Link>
|
||||
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ext = data.extension;
|
||||
const bundle = data.bundle;
|
||||
const isPlugin = ext.type === 'plugin';
|
||||
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/admin/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||
</Link>
|
||||
|
||||
{/* Banner / hero */}
|
||||
{ext.bannerUrl && (
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img
|
||||
src={ext.bannerUrl}
|
||||
alt=""
|
||||
className="block w-full max-h-64 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{ext.iconUrl ? (
|
||||
<img
|
||||
src={ext.iconUrl}
|
||||
alt=""
|
||||
className="w-14 h-14 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isPlugin ? (
|
||||
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
||||
) : (
|
||||
<SwatchBook className="w-7 h-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
|
||||
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||
{data.installed && (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
|
||||
<Check className="w-3 h-3" /> Installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
isPlugin
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||
}`}>
|
||||
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
|
||||
</span>
|
||||
{ext.author && (
|
||||
<span>by {ext.author.displayName}</span>
|
||||
)}
|
||||
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
|
||||
{ext.license && <span>{ext.license}</span>}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
{ext.totalDownloads.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
{data.installed ? (
|
||||
<>
|
||||
<Link
|
||||
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
Manage
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleUninstall}
|
||||
disabled={uninstalling}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Uninstall
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !!bundle.error}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bundle.error && (
|
||||
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Could not preview bundle</p>
|
||||
<p className="text-xs mt-0.5 opacity-90">{bundle.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">About</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
|
||||
{ext.longDescription && ext.longDescription !== ext.description && (
|
||||
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
|
||||
)}
|
||||
{ext.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{ext.tags.map(tag => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
|
||||
{ext.minAppVersion && <span>Requires app v{ext.minAppVersion}+</span>}
|
||||
{bundle.size > 0 && <span>Bundle: {(bundle.size / 1024).toFixed(1)} KB</span>}
|
||||
{ext.githubRepo && (
|
||||
<a
|
||||
href={`https://github.com/${ext.githubRepo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{ext.githubRepo}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Screenshots */}
|
||||
{ext.screenshots.length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||
{ext.screenshots.map((s, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={s.url}
|
||||
alt={s.altText || `Screenshot ${i + 1}`}
|
||||
className="w-full rounded-md border border-border bg-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Theme color preview */}
|
||||
{!isPlugin && ext.themePreviews.length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||
{ext.themePreviews.map(preview => (
|
||||
<ThemeColorSwatch key={preview.variant} preview={preview} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Permissions */}
|
||||
{isPlugin && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
|
||||
</div>
|
||||
{manifestPerms.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{manifestPerms.map(perm => {
|
||||
const risky = RISKY_PERMISSIONS.has(perm);
|
||||
return (
|
||||
<li
|
||||
key={perm}
|
||||
className={`flex items-center gap-2 text-sm rounded-md px-2 py-1 ${
|
||||
risky
|
||||
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
|
||||
: 'bg-muted/50 text-foreground'
|
||||
}`}
|
||||
>
|
||||
{risky && <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
|
||||
<code className="font-mono text-xs">{perm}</code>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{frameOrigins.length > 0 && (
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
The plugin will be allowed to embed content from these origins.
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{frameOrigins.map(origin => (
|
||||
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
|
||||
{origin}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Settings schema preview */}
|
||||
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">User settings</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
|
||||
<ul className="mt-3 divide-y divide-border">
|
||||
{Object.entries(settingsSchema).map(([key, field]) => (
|
||||
<li key={key} className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-foreground">{key}</code>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
|
||||
{field.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Source / manifest disclosure */}
|
||||
{bundle.manifest && (
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowManifest(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
|
||||
</div>
|
||||
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showManifest && (
|
||||
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
|
||||
{JSON.stringify(bundle.manifest, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bundle.source && (
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowSource(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
|
||||
{bundle.source.truncated && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
|
||||
)}
|
||||
</div>
|
||||
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showSource && (
|
||||
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
|
||||
{bundle.source.content}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Version history */}
|
||||
{ext.versions.length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Version history</h2>
|
||||
<ul className="mt-3 divide-y divide-border">
|
||||
{ext.versions.slice(0, 5).map(v => (
|
||||
<li key={v.version} className="py-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-foreground">v{v.version}</code>
|
||||
{v.publishedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(v.publishedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.changelog && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap">{v.changelog}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{(v.bundleSize / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
|
||||
const colors = preview.colors || {};
|
||||
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
|
||||
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
|
||||
const accent = colors.primary || colors.accent || '#7c5cff';
|
||||
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
|
||||
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
|
||||
{preview.variant}
|
||||
</div>
|
||||
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
|
||||
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
|
||||
</div>
|
||||
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
|
||||
<span style={{ color: fg }}>Card surface</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
title={`${key}: ${value}`}
|
||||
className="inline-block w-4 h-4 rounded border"
|
||||
style={{ background: value, borderColor: border }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=marketplace');
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAdminTabStore, isAdminTab } from '@/stores/admin-tab-store';
|
||||
import { DashboardTab } from './_tabs/dashboard';
|
||||
import { SettingsTab } from './_tabs/settings';
|
||||
import { BrandingTab } from './_tabs/branding';
|
||||
import { AuthTab } from './_tabs/auth';
|
||||
import { PolicyTab } from './_tabs/policy';
|
||||
import { PluginsTab } from './_tabs/plugins';
|
||||
import { ThemesTab } from './_tabs/themes';
|
||||
import { MarketplaceTab } from './_tabs/marketplace';
|
||||
import { VersionTab } from './_tabs/version';
|
||||
import { TelemetryTab } from './_tabs/telemetry';
|
||||
import { LogsTab } from './_tabs/logs';
|
||||
|
||||
export default function AdminPage() {
|
||||
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||
|
||||
// Honour deep links from the old route structure: /admin?tab=settings
|
||||
// (emitted by the redirect pages in /admin/<x>/page.tsx) sets the store
|
||||
// once on mount, then strips the param so the URL stays at /admin and
|
||||
// subsequent tab clicks don't accumulate query strings.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
const fromUrl = url.searchParams.get('tab');
|
||||
if (isAdminTab(fromUrl)) {
|
||||
setActiveTab(fromUrl);
|
||||
url.searchParams.delete('tab');
|
||||
window.history.replaceState(null, '', url.pathname + url.search + url.hash);
|
||||
}
|
||||
}, [setActiveTab]);
|
||||
|
||||
switch (activeTab) {
|
||||
case 'dashboard': return <DashboardTab />;
|
||||
case 'settings': return <SettingsTab />;
|
||||
case 'branding': return <BrandingTab />;
|
||||
case 'auth': return <AuthTab />;
|
||||
case 'policy': return <PolicyTab />;
|
||||
case 'plugins': return <PluginsTab />;
|
||||
case 'themes': return <ThemesTab />;
|
||||
case 'marketplace': return <MarketplaceTab />;
|
||||
case 'version': return <VersionTab />;
|
||||
case 'telemetry': return <TelemetryTab />;
|
||||
case 'logs': return <LogsTab />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Inline panel handles plugin config now - see _tabs/plugin-config-panel.tsx.
|
||||
// Old deep links land on the plugins tab; the user clicks the gear again.
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=plugins');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=plugins');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=policy');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=settings');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=telemetry');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=themes');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=version');
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/jmap
|
||||
*
|
||||
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
|
||||
* context so the browser does not need access to the user's credentials.
|
||||
*
|
||||
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
|
||||
*
|
||||
* In Stalwart 0.16 all management operations (password change, app
|
||||
* passwords, API keys, account settings, etc.) are exposed as JMAP
|
||||
* methods under the `x:` namespace on the same endpoint.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
|
||||
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
return new NextResponse(responseText, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Stalwart JMAP passthrough error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { readAuditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||
const limit = Math.min(200, Math.max(1, parseInt(request.nextUrl.searchParams.get('limit') || '50', 10)));
|
||||
const action = request.nextUrl.searchParams.get('action') || undefined;
|
||||
|
||||
const { entries, total } = await readAuditLog(page, limit, action);
|
||||
|
||||
return NextResponse.json({ entries, total, page, limit }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Audit log read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/branding/[filename] - Serve uploaded branding images
|
||||
*
|
||||
* This endpoint is public (no admin auth) so browsers can load images.
|
||||
* Only files in the branding directory are served; directory traversal is prevented.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
// Sanitize: only allow basename, no path separators
|
||||
const safe = path.basename(filename);
|
||||
if (safe !== filename || filename.includes('..')) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = path.extname(safe).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext];
|
||||
if (!contentType) {
|
||||
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = path.join(BRANDING_DIR, safe);
|
||||
|
||||
// Ensure resolved path is still within BRANDING_DIR
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
const fileStat = await stat(resolved).catch(() => null);
|
||||
if (!fileStat || !fileStat.isFile()) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const buffer = await readFile(resolved);
|
||||
|
||||
// SVG can carry inline <script> and event handlers that execute when the
|
||||
// file is fetched as a top-level document. Defense in depth on top of
|
||||
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
|
||||
// sandboxed unique origin so any script in an SVG is inert and cannot
|
||||
// touch app cookies or storage.
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||
'Content-Length': String(buffer.length),
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Security-Policy':
|
||||
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/svg+xml',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
]);
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
// Strip directory traversal, keep only safe chars
|
||||
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/branding - Upload a branding image file
|
||||
*
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the image file
|
||||
* - slot: which branding field this is for (e.g. "faviconUrl")
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const slot = formData.get('slot') as string | null;
|
||||
|
||||
if (!file || !slot) {
|
||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_SLOTS.has(slot)) {
|
||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Determine extension from mime type
|
||||
const extMap: Record<string, string> = {
|
||||
'image/svg+xml': '.svg',
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/x-icon': '.ico',
|
||||
'image/vnd.microsoft.icon': '.ico',
|
||||
};
|
||||
const ext = extMap[file.type] || '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
const filePath = path.join(BRANDING_DIR, safeName);
|
||||
|
||||
// Ensure branding directory exists
|
||||
if (!existsSync(BRANDING_DIR)) {
|
||||
await mkdir(BRANDING_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Write file to disk
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Update config to point to the served URL
|
||||
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
|
||||
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
|
||||
|
||||
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||
} catch (error) {
|
||||
logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||
*
|
||||
* Expects JSON body: { slot: string }
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { slot } = await request.json();
|
||||
|
||||
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find and remove matching files for this slot
|
||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||
let removed = false;
|
||||
for (const ext of possibleExts) {
|
||||
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the config override so it falls back to default/env
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.removeAdminOverride(slot);
|
||||
|
||||
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { changeAdminPassword } from '@/lib/admin/password';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* POST /api/admin/change-password - Change admin password
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { currentPassword, newPassword } = await request.json();
|
||||
|
||||
if (!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
||||
return NextResponse.json({ error: 'Both current and new password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const success = await changeAdminPassword(currentPassword, newPassword);
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
|
||||
}
|
||||
|
||||
await auditLog('admin.change-password', {}, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const config = configManager.getAllWithSources();
|
||||
|
||||
return NextResponse.json(config, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Admin config read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/config - Update config overrides (admin-protected)
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const updates = await request.json();
|
||||
|
||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate keys
|
||||
const validKeys = Object.keys(CONFIG_ENV_MAP);
|
||||
const invalidKeys = Object.keys(updates).filter(k => !validKeys.includes(k));
|
||||
if (invalidKeys.length > 0) {
|
||||
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Normalize jmapServers: pass through the parser so invalid entries are
|
||||
// rejected (bad ids, duplicate ids, non-HTTP URLs) before they're persisted.
|
||||
if ('jmapServers' in updates) {
|
||||
const incoming = updates.jmapServers;
|
||||
if (incoming != null && !Array.isArray(incoming)) {
|
||||
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseJmapServers(incoming);
|
||||
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json({
|
||||
error: 'One or more jmapServers entries are invalid (each needs a unique id, label, and HTTP(S) url).',
|
||||
}, { status: 400 });
|
||||
}
|
||||
updates.jmapServers = sanitized;
|
||||
}
|
||||
|
||||
// Get old values for audit
|
||||
const oldValues: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(updates)) {
|
||||
oldValues[key] = configManager.get(key);
|
||||
}
|
||||
|
||||
await configManager.setAdminConfig(updates);
|
||||
await auditLog('config.update', { changes: Object.keys(updates).map(k => ({ key: k, old: oldValues[k], new: updates[k] })) }, ip);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { key } = await request.json();
|
||||
|
||||
if (!key || typeof key !== 'string') {
|
||||
return NextResponse.json({ error: 'Key is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!CONFIG_ENV_MAP[key]) {
|
||||
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const oldValue = configManager.get(key);
|
||||
await configManager.removeAdminOverride(key);
|
||||
await auditLog('config.revert', { key, oldValue }, ip);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
|
||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||
|
||||
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace/[slug]
|
||||
* Returns full preview info for an extension: directory metadata,
|
||||
* the bundle's manifest, a (truncated) source preview, and install status.
|
||||
* Lets admins audit what they're about to install before pressing the button.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { slug } = await params;
|
||||
|
||||
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
||||
const detailRes = await fetch(detailUrl.toString(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!detailRes.ok) {
|
||||
const status = detailRes.status === 404 ? 404 : 502;
|
||||
return NextResponse.json(
|
||||
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
const detailJson = await detailRes.json();
|
||||
const extension = detailJson.data as Record<string, unknown> | undefined;
|
||||
if (!extension) {
|
||||
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const type = extension.type as 'plugin' | 'theme';
|
||||
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
|
||||
?? null;
|
||||
|
||||
// 2. Pull the bundle so we can show what's actually inside.
|
||||
let manifest: Record<string, unknown> | null = null;
|
||||
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
|
||||
let bundleError: string | null = null;
|
||||
let bundleSize = 0;
|
||||
|
||||
if (latestVersion) {
|
||||
try {
|
||||
const bundleUrl = new URL(
|
||||
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||
DIRECTORY_URL,
|
||||
);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!bundleRes.ok) {
|
||||
bundleError = `Bundle download failed (${bundleRes.status})`;
|
||||
} else {
|
||||
const buffer = await bundleRes.arrayBuffer();
|
||||
bundleSize = buffer.byteLength;
|
||||
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||
if (buffer.byteLength > maxSize) {
|
||||
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
|
||||
} else {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
// Detect optional root directory inside the ZIP.
|
||||
const entries = Object.keys(zip.files);
|
||||
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
|
||||
let root = '';
|
||||
if (topDirs.size === 1) {
|
||||
const dir = [...topDirs][0];
|
||||
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
|
||||
root = dir + '/';
|
||||
}
|
||||
}
|
||||
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
bundleError = 'Bundle missing manifest.json';
|
||||
} else {
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
bundleError = 'Invalid manifest.json in bundle';
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
if (type === 'theme') {
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (cssFile) {
|
||||
const css = await cssFile.async('string');
|
||||
sourcePreview = {
|
||||
name: 'theme.css',
|
||||
content: css.length > MAX_PREVIEW_SOURCE_LEN
|
||||
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||
: css,
|
||||
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||
const jsFile = zip.file(root + entrypoint);
|
||||
if (jsFile) {
|
||||
const code = await jsFile.async('string');
|
||||
sourcePreview = {
|
||||
name: entrypoint,
|
||||
content: code.length > MAX_PREVIEW_SOURCE_LEN
|
||||
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||
: code,
|
||||
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
bundleError = err instanceof Error ? err.message : 'Failed to read bundle';
|
||||
}
|
||||
} else {
|
||||
bundleError = 'Extension has no published version';
|
||||
}
|
||||
|
||||
// 3. Install status (slug is used as the registry id at install time)
|
||||
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
const installed = type === 'theme'
|
||||
? themeRegistry.themes.some((t) => t.id === slug)
|
||||
: pluginRegistry.plugins.some((p) => p.id === slug);
|
||||
|
||||
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||
const screenshots = Array.isArray(extension.screenshots)
|
||||
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
||||
altText: s.altText ?? null,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Strip the heavy `manifest` blob from versions when echoing the directory data.
|
||||
const versions = Array.isArray(extension.versions)
|
||||
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
|
||||
version: v.version,
|
||||
changelog: v.changelog,
|
||||
bundleSize: v.bundleSize,
|
||||
minAppVersion: v.minAppVersion,
|
||||
publishedAt: v.publishedAt,
|
||||
permissions: v.permissions,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const fileUrl = (path: unknown): string | null =>
|
||||
typeof path === 'string' && path
|
||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
||||
: null;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
extension: {
|
||||
slug: extension.slug,
|
||||
name: extension.name,
|
||||
type: extension.type,
|
||||
pluginType: extension.pluginType ?? null,
|
||||
description: extension.description,
|
||||
longDescription: extension.longDescription ?? null,
|
||||
tags: extension.tags ?? [],
|
||||
permissions: extension.permissions ?? [],
|
||||
totalDownloads: extension.totalDownloads ?? 0,
|
||||
featured: extension.featured ?? false,
|
||||
githubRepo: extension.githubRepo ?? null,
|
||||
license: extension.license ?? null,
|
||||
minAppVersion: extension.minAppVersion ?? null,
|
||||
iconUrl: fileUrl(extension.iconPath),
|
||||
bannerUrl: fileUrl(extension.bannerPath),
|
||||
author: extension.author ?? null,
|
||||
latestVersion,
|
||||
versions,
|
||||
screenshots,
|
||||
themePreviews: extension.themePreviews ?? [],
|
||||
createdAt: extension.createdAt ?? null,
|
||||
updatedAt: extension.updatedAt ?? null,
|
||||
},
|
||||
bundle: {
|
||||
manifest,
|
||||
source: sourcePreview,
|
||||
size: bundleSize,
|
||||
error: bundleError,
|
||||
},
|
||||
installed,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Marketplace preview error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load preview' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
savePlugin,
|
||||
saveTheme,
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
type ServerPlugin,
|
||||
type ServerTheme,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
|
||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||
* Proxies to the extension directory API
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
|
||||
|
||||
// Forward all search params
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch from extension directory' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Enrich with install status
|
||||
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
|
||||
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
||||
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
||||
|
||||
const fileUrl = (path: unknown): string | null =>
|
||||
typeof path === 'string' && path
|
||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
||||
: null;
|
||||
|
||||
if (data.data) {
|
||||
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
||||
...ext,
|
||||
iconUrl: fileUrl(ext.iconPath),
|
||||
bannerUrl: fileUrl(ext.bannerPath),
|
||||
installed: ext.type === 'theme'
|
||||
? installedThemes.has(ext.slug as string)
|
||||
: installedPlugins.has(ext.slug as string),
|
||||
}));
|
||||
}
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Marketplace search error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Failed to connect to extension directory' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/marketplace - Install an extension from the directory
|
||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { slug, version, type } = await request.json();
|
||||
|
||||
if (!slug || !version || !type) {
|
||||
return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (type !== 'plugin' && type !== 'theme') {
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Download the bundle from the directory
|
||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!bundleRes.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to download bundle: ${bundleRes.status}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await bundleRes.arrayBuffer();
|
||||
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||
|
||||
if (buffer.byteLength > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the ZIP
|
||||
let zip: JSZip;
|
||||
try {
|
||||
zip = await JSZip.loadAsync(buffer);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find root directory
|
||||
const entries = Object.keys(zip.files);
|
||||
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||
let root = '';
|
||||
if (topDirs.size === 1) {
|
||||
const dir = [...topDirs][0];
|
||||
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||
root = dir + '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Read manifest
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (type === 'theme') {
|
||||
// Read theme.css
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (!cssFile) {
|
||||
return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 });
|
||||
}
|
||||
|
||||
let css = await cssFile.async('string');
|
||||
|
||||
// Validate and sanitize CSS
|
||||
const warnings: string[] = [];
|
||||
const safety = validateThemeCSSSafety(css);
|
||||
if (!safety.valid) {
|
||||
const sanitized = sanitizeThemeCSS(css);
|
||||
css = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
}
|
||||
|
||||
const theme: ServerTheme = {
|
||||
id: (manifest.id as string) || slug,
|
||||
name: (manifest.name as string) || slug,
|
||||
version: (manifest.version as string) || version,
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
description: (manifest.description as string) || '',
|
||||
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await saveTheme(theme, css);
|
||||
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
|
||||
|
||||
return NextResponse.json({ success: true, theme, warnings });
|
||||
} else {
|
||||
// Plugin installation
|
||||
// Read entrypoint JS
|
||||
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||
const jsFile = zip.file(root + entrypoint);
|
||||
if (!jsFile) {
|
||||
return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = await jsFile.async('string');
|
||||
|
||||
// Block plugins with dangerous JS patterns
|
||||
const DANGEROUS_JS_PATTERNS = [
|
||||
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||
{ pattern: /document\.write/g, label: 'document.write' },
|
||||
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||
];
|
||||
const dangerousFindings: string[] = [];
|
||||
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) dangerousFindings.push(label);
|
||||
pattern.lastIndex = 0;
|
||||
}
|
||||
if (dangerousFindings.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate permissions
|
||||
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||
const unknownPerms = permissions.filter(p => !validPerms.has(p));
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (unknownPerms.length > 0) {
|
||||
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||
}
|
||||
|
||||
// Plugins may declare iframe origins they need for embedded content.
|
||||
// Anything that doesn't pass strict origin validation is silently
|
||||
// dropped - the plugin still installs, but those origins are not
|
||||
// added to the host CSP.
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||
? (manifest.frameOrigins as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedFrameOrigins.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
const droppedHttpOrigins = Array.isArray(manifest.httpOrigins)
|
||||
? (manifest.httpOrigins as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedHttpOrigins.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id: (manifest.id as string) || slug,
|
||||
name: (manifest.name as string) || slug,
|
||||
version: (manifest.version as string) || version,
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
description: (manifest.description as string) || '',
|
||||
type: (manifest.type as string) || 'hook',
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { locales as ALL_LOCALES } from '@/i18n/routing';
|
||||
|
||||
const CLIENT_ID = 'bulwark-webmail';
|
||||
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
|
||||
const JMAP_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface JmapMethodCall {
|
||||
using: string[];
|
||||
methodCalls: Array<[string, Record<string, unknown>, string]>;
|
||||
}
|
||||
|
||||
interface JmapMethodResponse {
|
||||
methodResponses?: Array<[string, Record<string, unknown>, string]>;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function jmapCall(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
body: JmapMethodCall,
|
||||
): Promise<JmapMethodResponse> {
|
||||
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res.json() as Promise<JmapMethodResponse>;
|
||||
}
|
||||
|
||||
async function getStalwartAccountId(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
): Promise<string | null> {
|
||||
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': authHeader },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const session = await res.json() as { primaryAccounts?: Record<string, string> };
|
||||
return session.primaryAccounts?.['urn:stalwart:jmap']
|
||||
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
|
||||
?? Object.values(session.primaryAccounts ?? {})[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
|
||||
const out: Record<string, true> = {};
|
||||
for (const loc of localeList) {
|
||||
out[`${origin}/${loc}/auth/callback`] = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface SetupRequestBody {
|
||||
origin?: string;
|
||||
issuerUrl?: string;
|
||||
locales?: string[];
|
||||
oauthOnly?: boolean;
|
||||
}
|
||||
|
||||
function isValidOriginUrl(value: string): boolean {
|
||||
return /^https?:\/\/[^/]+$/.test(value);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json() as SetupRequestBody;
|
||||
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
|
||||
if (!isValidOriginUrl(origin)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
|
||||
if (!isValidOriginUrl(issuerUrl)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const localeList = Array.isArray(body.locales) && body.locales.length > 0
|
||||
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
|
||||
: Array.from(ALL_LOCALES);
|
||||
if (localeList.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
|
||||
}
|
||||
const oauthOnly = body.oauthOnly === true;
|
||||
|
||||
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not resolve Stalwart account from JMAP session.' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
methodCalls: [[
|
||||
'x:OAuthClient/query',
|
||||
{ accountId, filter: { clientId: CLIENT_ID } },
|
||||
'0',
|
||||
]],
|
||||
});
|
||||
|
||||
const queryEntry = queryRes.methodResponses?.[0];
|
||||
if (!queryEntry || queryEntry[0] === 'error') {
|
||||
return NextResponse.json({
|
||||
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
|
||||
detail: queryEntry?.[1],
|
||||
}, { status: 403 });
|
||||
}
|
||||
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
|
||||
|
||||
const secret = randomBytes(32).toString('base64url');
|
||||
const redirectUris = buildRedirectUris(origin, localeList);
|
||||
|
||||
let setArgs: Record<string, unknown>;
|
||||
let action: 'created' | 'updated';
|
||||
if (existingIds.length > 0) {
|
||||
const targetId = existingIds[0];
|
||||
action = 'updated';
|
||||
setArgs = {
|
||||
accountId,
|
||||
update: {
|
||||
[targetId]: {
|
||||
secret,
|
||||
redirectUris,
|
||||
description: CLIENT_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
action = 'created';
|
||||
setArgs = {
|
||||
accountId,
|
||||
create: {
|
||||
new: {
|
||||
clientId: CLIENT_ID,
|
||||
description: CLIENT_DESCRIPTION,
|
||||
secret,
|
||||
redirectUris,
|
||||
contacts: { [creds.username]: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
|
||||
});
|
||||
|
||||
const setEntry = setRes.methodResponses?.[0];
|
||||
if (!setEntry || setEntry[0] === 'error') {
|
||||
return NextResponse.json({
|
||||
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
|
||||
detail: setEntry?.[1],
|
||||
}, { status: 403 });
|
||||
}
|
||||
const setBody = setEntry[1] as {
|
||||
notCreated?: Record<string, unknown>;
|
||||
notUpdated?: Record<string, unknown>;
|
||||
};
|
||||
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const updates: Record<string, unknown> = {
|
||||
oauthEnabled: true,
|
||||
oauthClientId: CLIENT_ID,
|
||||
oauthClientSecret: secret,
|
||||
oauthIssuerUrl: issuerUrl,
|
||||
};
|
||||
if (oauthOnly) updates.oauthOnly = true;
|
||||
await configManager.setAdminConfig(updates);
|
||||
|
||||
await auditLog('admin.oauth_setup', {
|
||||
action,
|
||||
clientId: CLIENT_ID,
|
||||
origin,
|
||||
issuer: issuerUrl,
|
||||
redirectUriCount: localeList.length,
|
||||
oauthOnly,
|
||||
}, ip);
|
||||
|
||||
logger.info('Admin OAuth setup', {
|
||||
action,
|
||||
clientId: CLIENT_ID,
|
||||
origin,
|
||||
issuer: issuerUrl,
|
||||
locales: localeList.length,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action,
|
||||
clientId: CLIENT_ID,
|
||||
origin,
|
||||
issuerUrl,
|
||||
redirectUriCount: localeList.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||
*
|
||||
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||
* Only serves plugins that exist in the registry and are enabled.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Validate ID format to prevent path traversal
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Dev plugins are read (and optionally bundled) straight from disk and
|
||||
// served with no caching so every refresh picks up the latest source.
|
||||
const devEntry = await getDevPlugin(id);
|
||||
if (devEntry) {
|
||||
const code = await readDevBundle(devEntry);
|
||||
return new NextResponse(code, {
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const plugin = await getPlugin(id);
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!plugin.enabled) {
|
||||
return NextResponse.json({ error: 'Plugin is disabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
const code = await getPluginBundle(id);
|
||||
if (!code) {
|
||||
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Use the registry's bundleHash as the ETag so the browser can revalidate
|
||||
// cheaply. Cache-Control: no-cache forces revalidation on every request,
|
||||
// but a matching If-None-Match returns 304 with no body.
|
||||
const etag = plugin.bundleHash ? `"${plugin.bundleHash}"` : undefined;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'private, no-cache, must-revalidate',
|
||||
};
|
||||
if (etag) headers['ETag'] = etag;
|
||||
|
||||
if (etag && request.headers.get('if-none-match') === etag) {
|
||||
return new NextResponse(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
headers['Content-Length'] = String(Buffer.byteLength(code, 'utf-8'));
|
||||
return new NextResponse(code, { headers });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin } from '@/lib/admin/plugin-dev';
|
||||
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */
|
||||
async function resolvePlugin(id: string) {
|
||||
const registered = await getPlugin(id);
|
||||
if (registered) return registered;
|
||||
const dev = await getDevPlugin(id);
|
||||
return dev?.plugin ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/config - Read plugin config
|
||||
*
|
||||
* - Admin sessions receive every field, including those declared
|
||||
* `type: 'secret'` in the plugin's configSchema.
|
||||
* - Authenticated mailbox users (the plugin running in their browser)
|
||||
* receive only non-secret fields.
|
||||
* - Anonymous callers are rejected so unauthenticated visitors cannot
|
||||
* enumerate plugin secrets.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminAuth = await requireAdminAuth();
|
||||
const isAdmin = !('error' in adminAuth);
|
||||
|
||||
if (!isAdmin) {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = await resolvePlugin(id);
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const config = await getPluginConfig(id);
|
||||
|
||||
let response: Record<string, unknown> = config;
|
||||
if (!isAdmin && plugin.configSchema) {
|
||||
response = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const field = plugin.configSchema[key];
|
||||
if (field?.type === 'secret') continue;
|
||||
response[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/plugins/[id]/config - Set a config key
|
||||
*
|
||||
* Body: { key: string, value: unknown }
|
||||
* Requires admin authentication (checked via admin session).
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const plugin = await resolvePlugin(id);
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: { key?: string; value?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.key || typeof body.key !== 'string') {
|
||||
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate key format (alphanumeric, hyphens, underscores, dots)
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
|
||||
await setPluginConfig(id, body.key, body.value);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/plugins/[id]/config - Delete a config key
|
||||
*
|
||||
* Body: { key: string }
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
let body: { key?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.key || typeof body.key !== 'string') {
|
||||
return NextResponse.json({ error: 'key is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await deletePluginConfigKey(id, body.key);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getPluginRegistry,
|
||||
savePlugin,
|
||||
deletePlugin as removePlugin,
|
||||
type ServerPlugin,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
// Server-side extraction using the same validation logic
|
||||
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
|
||||
const SUSPICIOUS_JS_PATTERNS = [
|
||||
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||
{ pattern: /document\.write/g, label: 'document.write' },
|
||||
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||
];
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins - List all admin-managed plugins
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const [registry, devEntries] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
listDevPlugins(),
|
||||
]);
|
||||
|
||||
// Dev plugins win on id collision so admins see what users actually load.
|
||||
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||
const merged = [
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true as const })),
|
||||
...registry.plugins
|
||||
.filter(p => !devIds.has(p.id))
|
||||
.map(p => ({ ...p, dev: false as const })),
|
||||
];
|
||||
return NextResponse.json(merged, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_PLUGIN_SIZE) {
|
||||
return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Extract and validate ZIP
|
||||
let zip: JSZip;
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
zip = await JSZip.loadAsync(buffer);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find root
|
||||
const entries = Object.keys(zip.files);
|
||||
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||
let root = '';
|
||||
if (topDirs.size === 1) {
|
||||
const dir = [...topDirs][0];
|
||||
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||
root = dir + '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Read manifest
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate manifest
|
||||
const errors: string[] = [];
|
||||
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||
if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"');
|
||||
|
||||
const validTypes = ['ui-extension', 'sidebar-app', 'hook'];
|
||||
if (!validTypes.includes(manifest.type as string)) {
|
||||
errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
|
||||
}
|
||||
|
||||
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||
}
|
||||
|
||||
if (manifest.permissions && Array.isArray(manifest.permissions)) {
|
||||
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||
const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
|
||||
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check file extensions
|
||||
for (const [filePath, entry] of Object.entries(zip.files)) {
|
||||
if (entry.dir) continue;
|
||||
const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : '';
|
||||
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
|
||||
errors.push(`Disallowed file type: ${filePath}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||
}
|
||||
|
||||
// Read entrypoint code
|
||||
const entryFile = zip.file(root + (manifest.entrypoint as string));
|
||||
if (!entryFile) {
|
||||
return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 });
|
||||
}
|
||||
const code = await entryFile.async('string');
|
||||
|
||||
// Security: block plugins containing dangerous JS patterns
|
||||
const warnings: string[] = [];
|
||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||
pattern.lastIndex = 0;
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
id: manifest.id as string,
|
||||
name: manifest.name as string,
|
||||
version: manifest.version as string,
|
||||
author: manifest.author as string,
|
||||
description: (manifest.description as string) || '',
|
||||
type: manifest.type as string,
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
} catch (error) {
|
||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
|
||||
* Body: { id: string, enabled: boolean }
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { id, enabled, forceEnabled } = await request.json();
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
||||
}
|
||||
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||
|
||||
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||
const updated = await updatePluginMeta(id, updates);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Enable/disable changes the set of plugins contributing frame origins.
|
||||
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
||||
invalidateFrameOriginsCache();
|
||||
}
|
||||
|
||||
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||
return NextResponse.json({ plugin: updated });
|
||||
} catch (error) {
|
||||
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/plugins - Remove a plugin
|
||||
* Body: { id: string }
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { id } = await request.json();
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const removed = await removePlugin(id);
|
||||
if (!removed) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.delete', { id }, ip);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
|
||||
/**
|
||||
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
return NextResponse.json(policy, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/policy - Update settings policy (admin-protected)
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const policy = await request.json() as SettingsPolicy;
|
||||
|
||||
if (!policy || typeof policy !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid policy object' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (policy.restrictions && typeof policy.restrictions !== 'object') {
|
||||
return NextResponse.json({ error: 'restrictions must be an object' }, { status: 400 });
|
||||
}
|
||||
if (policy.features && typeof policy.features !== 'object') {
|
||||
return NextResponse.json({ error: 'features must be an object' }, { status: 400 });
|
||||
}
|
||||
if (policy.themePolicy && typeof policy.themePolicy !== 'object') {
|
||||
return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
await configManager.setPolicy(policy);
|
||||
await auditLog('policy.update', { restrictionCount: Object.keys(policy.restrictions || {}).length }, ip);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
effectiveConsent,
|
||||
loadState,
|
||||
saveState,
|
||||
buildPayload,
|
||||
sendOnce,
|
||||
reschedule,
|
||||
DEFAULT_ENDPOINT,
|
||||
getLoginCounts,
|
||||
resolveEndpointAllowed,
|
||||
} from '@/lib/telemetry';
|
||||
|
||||
/**
|
||||
* GET /api/admin/telemetry
|
||||
* Returns current consent + endpoint + next/last send + a live preview
|
||||
* of exactly what the next heartbeat would contain.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const { consent, source, state } = await effectiveConsent();
|
||||
const [payload, accountCounts] = await Promise.all([
|
||||
buildPayload(),
|
||||
getLoginCounts(),
|
||||
]);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
consent,
|
||||
consentSource: source,
|
||||
endpoint: state.endpoint || DEFAULT_ENDPOINT,
|
||||
consentedAt: state.consentedAt,
|
||||
lastSentAt: state.lastSentAt,
|
||||
nextScheduledAt: state.nextScheduledAt,
|
||||
defaultEndpoint: DEFAULT_ENDPOINT,
|
||||
payloadPreview: payload,
|
||||
accountCounts,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('telemetry GET error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/telemetry
|
||||
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
|
||||
* set-consent : { action, consent: 'on' | 'off' }
|
||||
* set-endpoint : { action, endpoint: string }
|
||||
* send-now : { action }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
if ('error' in auth) return auth.error;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
const body = (await request.json().catch(() => null)) as
|
||||
| { action?: string; consent?: string; endpoint?: string }
|
||||
| null;
|
||||
if (!body || typeof body.action !== 'string') {
|
||||
return NextResponse.json({ error: 'action required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { source } = await effectiveConsent();
|
||||
|
||||
if (body.action === 'set-consent') {
|
||||
if (source === 'env') {
|
||||
return NextResponse.json(
|
||||
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (body.consent !== 'on' && body.consent !== 'off') {
|
||||
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
|
||||
}
|
||||
const state = await loadState();
|
||||
const before = state.consent;
|
||||
state.consent = body.consent;
|
||||
if (body.consent === 'on' && !state.consentedAt) {
|
||||
state.consentedAt = new Date().toISOString();
|
||||
}
|
||||
await saveState(state);
|
||||
await reschedule();
|
||||
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (body.action === 'set-endpoint') {
|
||||
if (typeof body.endpoint !== 'string') {
|
||||
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
|
||||
}
|
||||
const trimmed = body.endpoint.trim();
|
||||
if (trimmed) {
|
||||
const check = await resolveEndpointAllowed(trimmed);
|
||||
if (!check.ok) {
|
||||
return NextResponse.json({ error: check.reason }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const state = await loadState();
|
||||
const before = state.endpoint;
|
||||
state.endpoint = trimmed || DEFAULT_ENDPOINT;
|
||||
await saveState(state);
|
||||
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
|
||||
return NextResponse.json({ ok: true, endpoint: state.endpoint });
|
||||
}
|
||||
|
||||
if (body.action === 'send-now') {
|
||||
const result = await sendOnce({ reason: 'admin-manual' });
|
||||
await auditLog(
|
||||
'telemetry.send-now',
|
||||
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
|
||||
ip,
|
||||
);
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
||||
} catch (err) {
|
||||
logger.error('telemetry POST error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Validate ID format
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify theme exists and is enabled
|
||||
const registry = await getThemeRegistry();
|
||||
const theme = registry.themes.find(t => t.id === id);
|
||||
if (!theme) {
|
||||
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||
}
|
||||
if (!theme.enabled) {
|
||||
return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
const css = await getThemeCSS(id);
|
||||
if (!css) {
|
||||
return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(css, {
|
||||
headers: {
|
||||
'Content-Type': 'text/css; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getThemeRegistry,
|
||||
saveTheme,
|
||||
deleteTheme as removeTheme,
|
||||
type ServerTheme,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes - List all admin-managed themes
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const registry = await getThemeRegistry();
|
||||
return NextResponse.json(registry.themes, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Theme list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/themes - Upload and install a theme ZIP
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_THEME_SIZE) {
|
||||
return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Extract and validate ZIP
|
||||
let zip: JSZip;
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
zip = await JSZip.loadAsync(buffer);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find root
|
||||
const entries = Object.keys(zip.files);
|
||||
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||
let root = '';
|
||||
if (topDirs.size === 1) {
|
||||
const dir = [...topDirs][0];
|
||||
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||
root = dir + '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Read manifest
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate manifest
|
||||
const errors: string[] = [];
|
||||
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||
|
||||
if (manifest.type !== 'theme') {
|
||||
errors.push(`Expected type "theme", got "${manifest.type}"`);
|
||||
}
|
||||
|
||||
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||
}
|
||||
|
||||
if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) {
|
||||
errors.push('Missing or empty "variants" array');
|
||||
} else {
|
||||
const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark');
|
||||
if (!valid) errors.push('Variants must be "light" or "dark"');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||
}
|
||||
|
||||
// Read theme.css
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (!cssFile) {
|
||||
return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 });
|
||||
}
|
||||
|
||||
let css = await cssFile.async('string');
|
||||
|
||||
// Validate and sanitize CSS
|
||||
const warnings: string[] = [];
|
||||
const safety = validateThemeCSSSafety(css);
|
||||
if (!safety.valid) {
|
||||
const sanitized = sanitizeThemeCSS(css);
|
||||
css = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const theme: ServerTheme = {
|
||||
id: manifest.id as string,
|
||||
name: manifest.name as string,
|
||||
version: manifest.version as string,
|
||||
author: manifest.author as string,
|
||||
description: (manifest.description as string) || '',
|
||||
variants: manifest.variants as string[],
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await saveTheme(theme, css);
|
||||
await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip);
|
||||
|
||||
return NextResponse.json({ theme, warnings });
|
||||
} catch (error) {
|
||||
logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
|
||||
* Body: { id: string, enabled: boolean }
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { id, enabled, forceEnabled } = await request.json();
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
||||
}
|
||||
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||
|
||||
const { updateThemeMeta } = await import('@/lib/admin/plugin-registry');
|
||||
const updated = await updateThemeMeta(id, updates);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await auditLog('theme.update', { id, ...updates }, ip);
|
||||
return NextResponse.json({ theme: updated });
|
||||
} catch (error) {
|
||||
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/themes - Remove a theme
|
||||
* Body: { id: string }
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { id } = await request.json();
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const removed = await removeTheme(id);
|
||||
if (!removed) {
|
||||
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await auditLog('theme.delete', { id }, ip);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
loadState,
|
||||
checkOnce,
|
||||
effectiveEndpoint,
|
||||
disabledByEnv,
|
||||
DEFAULT_VERSION_ENDPOINT,
|
||||
} from '@/lib/version-check';
|
||||
|
||||
/**
|
||||
* GET /api/admin/version
|
||||
* Returns the cached update status, last check times, and effective config.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const state = await loadState();
|
||||
return NextResponse.json(
|
||||
{
|
||||
current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
|
||||
build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
|
||||
endpoint: effectiveEndpoint(state),
|
||||
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
|
||||
disabledByEnv: disabledByEnv(),
|
||||
lastCheckedAt: state.lastCheckedAt,
|
||||
lastSuccessAt: state.lastSuccessAt,
|
||||
nextScheduledAt: state.nextScheduledAt,
|
||||
status: state.status,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('version admin GET error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/version
|
||||
* { action: 'check-now' } - force a fresh upstream fetch.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
||||
if (!body || body.action !== 'check-now') {
|
||||
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await checkOnce({ reason: 'admin-trigger' });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
logger.error('version admin POST error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+151
-13
@@ -2,38 +2,106 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import {
|
||||
clearStalwartAuthContextInStore,
|
||||
setStalwartAuthContextInStore,
|
||||
} from '@/lib/stalwart/auth-context';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
const slot = parseInt(raw, 10);
|
||||
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||
return slot;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, password } = await request.json();
|
||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
|
||||
if (oauthEnabled && oauthOnly) {
|
||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const token = encryptSession(serverUrl, username, password);
|
||||
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. We accept the global
|
||||
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
||||
// we fall back to the request URL only if `allowCustomJmapEndpoint` is on
|
||||
// - and even then the URL must resolve to a public address.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
process.env.JMAP_SERVER_URL ||
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||
|
||||
let upstreamUrl: string;
|
||||
let upstreamTrusted: boolean;
|
||||
if (trustedUrl) {
|
||||
upstreamUrl = trustedUrl;
|
||||
upstreamTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
upstreamTrusted = false;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const cookieName = sessionCookieName(slot);
|
||||
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||
const token = encryptSession(normalizedServerUrl, username, password);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
authHeader,
|
||||
});
|
||||
|
||||
void recordLogin(username, normalizedServerUrl);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const slot = getSlot(request);
|
||||
const cookieName = sessionCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
const token = cookieStore.get(cookieName)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||
@@ -41,10 +109,67 @@ export async function GET() {
|
||||
|
||||
const credentials = decryptSession(token);
|
||||
if (!credentials) {
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
cookieStore.delete(cookieName);
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||
}
|
||||
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||
});
|
||||
|
||||
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||
const { serverUrl, username } = credentials;
|
||||
return NextResponse.json(
|
||||
{ serverUrl, username },
|
||||
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT - retrieve full credentials (including password) for session restoration.
|
||||
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
// Require all Sec-Fetch-* headers to match a same-origin fetch() call.
|
||||
// Browsers set these automatically and they cannot be overridden by JS.
|
||||
const secFetchSite = request.headers.get('sec-fetch-site');
|
||||
const secFetchMode = request.headers.get('sec-fetch-mode');
|
||||
const secFetchDest = request.headers.get('sec-fetch-dest');
|
||||
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const slot = getSlot(request);
|
||||
const cookieName = sessionCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(cookieName)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||
}
|
||||
|
||||
const credentials = decryptSession(token);
|
||||
if (!credentials) {
|
||||
cookieStore.delete(cookieName);
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||
}
|
||||
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||
});
|
||||
|
||||
return NextResponse.json(credentials, {
|
||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||
});
|
||||
@@ -54,10 +179,23 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||
|
||||
if (all) {
|
||||
// Delete all session cookies (slots 0-4)
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
cookieStore.delete(sessionCookieName(i));
|
||||
clearStalwartAuthContextInStore(cookieStore, i);
|
||||
}
|
||||
} else {
|
||||
const slot = getSlot(request);
|
||||
cookieStore.delete(sessionCookieName(slot));
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptPayload } from '@/lib/auth/crypto';
|
||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
try {
|
||||
const { code, state, slot: bodySlot } = await request.json();
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Per-account refresh-token cookie slot. Without this the route hardcoded
|
||||
// slot 0, so the "+ Add Account" flow overwrote the first account's
|
||||
// refresh-token cookie. Default to 0 for back-compat with any caller that
|
||||
// omits slot. Mirrors the validation in /api/auth/token POST.
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||
|
||||
// Read and decrypt the pending SSO cookie
|
||||
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||
if (!pendingCookie) {
|
||||
logger.warn('SSO complete: no pending cookie found');
|
||||
return NextResponse.json({ error: 'No pending SSO session. Please start the login flow again.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const pending = decryptPayload(pendingCookie);
|
||||
if (!pending) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'Invalid SSO session' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate state
|
||||
if (pending.state !== state) {
|
||||
logger.warn('SSO complete: state mismatch');
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate TTL
|
||||
const createdAt = pending.created_at as number;
|
||||
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
|
||||
logger.warn('SSO complete: pending session expired');
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'SSO session expired. Please try again.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const codeVerifier = pending.code_verifier as string;
|
||||
const redirectUri = pending.redirect_uri as string;
|
||||
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||
|
||||
if (!codeVerifier || !redirectUri) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'Invalid SSO session data' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||
|
||||
// Store refresh token in the per-account cookie slot.
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
// Delete pending cookie
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up pending cookie on any error
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
logger.error('SSO complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { redirect_uri, locale, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||
try {
|
||||
const redirectOrigin = new URL(redirect_uri).origin;
|
||||
if (redirectOrigin !== requestOrigin) {
|
||||
logger.warn('SSO start: redirect_uri origin mismatch', { redirectOrigin, requestOrigin });
|
||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Generate PKCE + state server-side
|
||||
const codeVerifier = generateCodeVerifierServer();
|
||||
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||
const state = generateStateServer();
|
||||
|
||||
// Encrypt and store in httpOnly cookie. server_id is captured here so the
|
||||
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
||||
const pendingData = {
|
||||
state,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri,
|
||||
created_at: Date.now(),
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
const cookieStore = await cookies();
|
||||
const baseCookieOpts = getCookieOptions();
|
||||
cookieStore.set(SSO_PENDING_COOKIE, encrypted, {
|
||||
...baseCookieOpts,
|
||||
maxAge: SSO_PENDING_MAX_AGE,
|
||||
});
|
||||
|
||||
// Build authorize URL
|
||||
const authUrl = new URL(metadata.authorization_endpoint);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||
authUrl.searchParams.set('scope', OAUTH_SCOPES);
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
if (locale) {
|
||||
authUrl.searchParams.set('ui_locales', locale);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
authorize_url: authUrl.toString(),
|
||||
state,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('SSO start error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
|
||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||
return bodySlot;
|
||||
}
|
||||
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
|
||||
const slot = parseInt(raw, 10);
|
||||
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
|
||||
|
||||
if (!serverUrl || !username || !authHeader) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server (single `jmapServerUrl`
|
||||
// or any entry in `jmapServers`). Falls back to the request URL only when
|
||||
// `allowCustomJmapEndpoint` is enabled, and even then it must be public.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
process.env.JMAP_SERVER_URL ||
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||
|
||||
let upstreamUrl: string;
|
||||
let upstreamTrusted: boolean;
|
||||
if (trustedUrl) {
|
||||
upstreamUrl = trustedUrl;
|
||||
upstreamTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
upstreamTrusted = false;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const slot = getSlot(request, bodySlot);
|
||||
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||
|
||||
await setStalwartAuthContext(slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
authHeader,
|
||||
});
|
||||
|
||||
void recordLogin(username, normalizedServerUrl);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
logger.error('Failed to store Stalwart auth context', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+75
-88
@@ -1,100 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
};
|
||||
|
||||
function getRequiredConfig() {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
const issuerUrl = process.env.OAUTH_ISSUER_URL;
|
||||
if (!clientId || !serverUrl) {
|
||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
||||
}
|
||||
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
||||
if (issuerUrl !== undefined && !issuerUrl.trim()) {
|
||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
||||
}
|
||||
return { clientId, serverUrl, discoveryUrl };
|
||||
}
|
||||
|
||||
async function getTokenEndpoint(): Promise<string> {
|
||||
const { discoveryUrl } = getRequiredConfig();
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
if (!metadata?.token_endpoint) {
|
||||
throw new Error('OAuth token endpoint not found');
|
||||
}
|
||||
return metadata.token_endpoint;
|
||||
}
|
||||
|
||||
async function getMetadata(): Promise<import('@/lib/oauth/discovery').OAuthMetadata | null> {
|
||||
const { discoveryUrl } = getRequiredConfig();
|
||||
return discoverOAuth(discoveryUrl);
|
||||
}
|
||||
|
||||
function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
||||
const { clientId } = getRequiredConfig();
|
||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
||||
if (CLIENT_SECRET) {
|
||||
params.set('client_secret', CLIENT_SECRET);
|
||||
}
|
||||
return params;
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
const slot = parseInt(raw, 10);
|
||||
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||
return slot;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { code, code_verifier, redirect_uri } = await request.json();
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!code || !code_verifier || !redirect_uri) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await getTokenEndpoint();
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
const params = buildOAuthParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.error('Token exchange failed', { status: tokenResponse.status, error: errorText });
|
||||
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
if (!tokens.access_token) {
|
||||
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
|
||||
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
||||
}
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
|
||||
|
||||
const response = NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in || 3600,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
// Persist which server entry minted this refresh token so the PUT/DELETE
|
||||
// handlers can route the refresh/revocation calls to the right token
|
||||
// endpoint without the client having to track it across page loads.
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -104,21 +53,24 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT() {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const slot = getSlot(request);
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await getTokenEndpoint();
|
||||
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||
|
||||
const params = buildOAuthParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}, serverId);
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
@@ -129,7 +81,8 @@ export async function PUT() {
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
||||
cookieStore.delete(cookieName);
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -141,7 +94,7 @@ export async function PUT() {
|
||||
}
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -154,11 +107,44 @@ export async function PUT() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||
|
||||
if (all) {
|
||||
// Revoke and delete all refresh token cookies (slots 0-4)
|
||||
const cookieStore = await cookies();
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const name = refreshTokenCookieName(i);
|
||||
const serverCookieName = refreshTokenServerCookieName(i);
|
||||
const token = cookieStore.get(name)?.value;
|
||||
const slotServerId = cookieStore.get(serverCookieName)?.value || null;
|
||||
if (token) {
|
||||
// Best-effort revocation
|
||||
try {
|
||||
const metadata = await getMetadata(slotServerId).catch(() => null);
|
||||
if (metadata?.revocation_endpoint) {
|
||||
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }, slotServerId);
|
||||
await fetch(metadata.revocation_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
cookieStore.delete(name);
|
||||
}
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const slot = getSlot(request);
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
||||
const metadata = await getMetadata().catch((err) => {
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
const slotServerId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
const metadata = await getMetadata(slotServerId).catch((err) => {
|
||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
@@ -170,7 +156,7 @@ export async function DELETE() {
|
||||
const params = buildOAuthParams({
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
});
|
||||
}, slotServerId);
|
||||
|
||||
try {
|
||||
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
||||
@@ -186,8 +172,9 @@ export async function DELETE() {
|
||||
}
|
||||
}
|
||||
|
||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
||||
cookieStore.delete(cookieName);
|
||||
}
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
|
||||
let end_session_url: string | undefined;
|
||||
if (metadata?.end_session_endpoint) {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
*
|
||||
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||
*
|
||||
* Tries three strategies:
|
||||
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||
* 2. ROPC grant without client_id
|
||||
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||
*/
|
||||
|
||||
async function tryTokenRequest(
|
||||
tokenEndpoint: string,
|
||||
params: URLSearchParams,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
if (!tokens.access_token) {
|
||||
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||
}
|
||||
|
||||
return { ok: true, tokens };
|
||||
} catch (err) {
|
||||
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl);
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
const candidates = [
|
||||
`${serverUrl}/auth/token`,
|
||||
`${serverUrl}/api/oauth/token`,
|
||||
];
|
||||
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||
if (probe.status !== 404 && probe.status !== 405) {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// Network error - endpoint not reachable
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||
// no server is configured (and the deployment explicitly allows custom
|
||||
// JMAP endpoints) do we fall back to the user-supplied URL - and even then
|
||||
// it must resolve to a public address.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
process.env.JMAP_SERVER_URL ||
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
|
||||
let upstreamUrl: string;
|
||||
let resolvedServerId: string | null = null;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||
if (!tokenEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
}
|
||||
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
||||
} catch (error) {
|
||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function attemptAllStrategies(
|
||||
tokenEndpoint: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
// Strategy 1: ROPC with client_id (if configured)
|
||||
if (clientId) {
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||
if (clientSecret) params.set('client_secret', clientSecret);
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 2: ROPC without client_id
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||
}
|
||||
|
||||
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||
return NextResponse.json({
|
||||
error: 'token_exchange_failed',
|
||||
detail: 'All token exchange strategies failed',
|
||||
attempts,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
async function storeAndRespond(
|
||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
const cookieStore = await cookies();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in || 3600,
|
||||
has_refresh_token: !!tokens.refresh_token,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
interface DiscoveryAccountRequest {
|
||||
key: string;
|
||||
candidates: string[];
|
||||
}
|
||||
|
||||
interface DiscoveryResult {
|
||||
url: string | null;
|
||||
resolvedAccount: string | null;
|
||||
}
|
||||
|
||||
function buildPublicUrl(serverUrl: string, path: string): string {
|
||||
return new URL(path, serverUrl).toString();
|
||||
}
|
||||
|
||||
async function probeCalendarHome(serverUrl: string, authHeader: string, accountName: string): Promise<string | null> {
|
||||
const targetUrl = buildPublicUrl(serverUrl, `/dav/cal/${encodeURIComponent(accountName)}`);
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Depth: '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
body: `<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:displayname/>
|
||||
</D:prop>
|
||||
</D:propfind>`,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (response.status === 207) {
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
return new URL(location, targetUrl).toString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const accounts = Array.isArray(body.accounts) ? body.accounts as DiscoveryAccountRequest[] : [];
|
||||
const wellKnownUrl = buildPublicUrl(creds.serverUrl, '/.well-known/caldav');
|
||||
const discovered: Record<string, DiscoveryResult> = {};
|
||||
|
||||
for (const account of accounts) {
|
||||
if (!account?.key) continue;
|
||||
|
||||
const candidates = Array.from(new Set(
|
||||
(account.candidates || [])
|
||||
.map((candidate) => candidate?.trim())
|
||||
.filter((candidate): candidate is string => Boolean(candidate))
|
||||
));
|
||||
|
||||
let url: string | null = null;
|
||||
let resolvedAccount: string | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
url = await probeCalendarHome(creds.serverUrl, creds.authHeader, candidate);
|
||||
if (url) {
|
||||
resolvedAccount = candidate;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('CalDAV discovery probe failed', {
|
||||
accountKey: account.key,
|
||||
candidate,
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
discovered[account.key] = { url, resolvedAccount };
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
wellKnownUrl,
|
||||
accounts: discovered,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('CalDAV discovery failed', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+44
-11
@@ -1,26 +1,59 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
*
|
||||
* This endpoint serves configuration values that can be set at runtime
|
||||
* via environment variables, enabling post-build configuration for
|
||||
* Docker deployments.
|
||||
* via environment variables or admin dashboard overrides, enabling
|
||||
* post-build configuration for Docker deployments.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 2. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 3. Default values
|
||||
* 1. Admin dashboard overrides (data/admin/config.json)
|
||||
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 4. Default values
|
||||
*/
|
||||
export async function GET() {
|
||||
logger.debug('Config requested');
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
||||
|
||||
return NextResponse.json({
|
||||
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
|
||||
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
|
||||
oauthEnabled: process.env.OAUTH_ENABLED === 'true',
|
||||
oauthClientId: process.env.OAUTH_CLIENT_ID || '',
|
||||
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
|
||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
||||
appName,
|
||||
jmapServerUrl,
|
||||
oauthEnabled,
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,511 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// In-memory LRU cache: domain -> { data, contentType, fetchedAt }
|
||||
const CACHE_MAX_SIZE = 1000;
|
||||
const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 2 weeks
|
||||
|
||||
interface CacheEntry {
|
||||
data: ArrayBuffer;
|
||||
contentType: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface NegativeCacheEntry {
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const negativeCache = new Map<string, NegativeCacheEntry>();
|
||||
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
||||
|
||||
// Strict domain validation to prevent SSRF
|
||||
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
||||
|
||||
function isValidDomain(domain: string): boolean {
|
||||
if (domain.length > 253) return false;
|
||||
if (!DOMAIN_RE.test(domain)) return false;
|
||||
// Block internal/private hostnames
|
||||
const lower = domain.toLowerCase();
|
||||
if (
|
||||
lower === 'localhost' ||
|
||||
lower.endsWith('.local') ||
|
||||
lower.endsWith('.internal') ||
|
||||
lower.endsWith('.arpa')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Known multi-part TLDs where the registrable domain includes one extra label.
|
||||
const MULTI_PART_TLDS = new Set([
|
||||
// .ac
|
||||
"com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac",
|
||||
// .ae
|
||||
"ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae",
|
||||
// .af
|
||||
"com.af", "edu.af", "gov.af", "net.af", "org.af",
|
||||
// .al
|
||||
"com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al",
|
||||
// .ao
|
||||
"co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao",
|
||||
// .ar
|
||||
"com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar",
|
||||
// .at
|
||||
"ac.at", "co.at", "gv.at", "or.at",
|
||||
// .au
|
||||
"asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au",
|
||||
// .ba
|
||||
"co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba",
|
||||
"unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba",
|
||||
// .bb
|
||||
"biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb",
|
||||
"store.bb", "tv.bb",
|
||||
// .bh
|
||||
"biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh",
|
||||
// .bn
|
||||
"com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn",
|
||||
// .bo
|
||||
"com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo",
|
||||
// .br
|
||||
"adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br",
|
||||
"bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br",
|
||||
"ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br",
|
||||
"fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br",
|
||||
"ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br",
|
||||
"mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br",
|
||||
"pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br",
|
||||
"trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br",
|
||||
// .bs
|
||||
"com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs",
|
||||
// .bz
|
||||
"com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz",
|
||||
// .ca
|
||||
"ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca",
|
||||
"nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
|
||||
// .ck
|
||||
"biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck",
|
||||
// .cn
|
||||
"ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn",
|
||||
"gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn",
|
||||
"hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn",
|
||||
"nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn",
|
||||
"sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
|
||||
// .co
|
||||
"com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co",
|
||||
// .cr
|
||||
"ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr",
|
||||
// .cy
|
||||
"ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy",
|
||||
"net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy",
|
||||
// .do
|
||||
"art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do",
|
||||
"sld.do", "web.do",
|
||||
// .dz
|
||||
"art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz",
|
||||
// .ec
|
||||
"com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec",
|
||||
"org.ec", "pro.ec",
|
||||
// .eg
|
||||
"com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg",
|
||||
// .er
|
||||
"com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er",
|
||||
// .es
|
||||
"com.es", "edu.es", "gob.es", "nom.es", "org.es",
|
||||
// .et
|
||||
"biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et",
|
||||
// .fj
|
||||
"ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj",
|
||||
// .fk
|
||||
"ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk",
|
||||
// .fr
|
||||
"asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr",
|
||||
// .gg
|
||||
"co.gg", "net.gg", "org.gg",
|
||||
// .gh
|
||||
"com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh",
|
||||
// .gn
|
||||
"ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn",
|
||||
// .gr
|
||||
"com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr",
|
||||
// .gt
|
||||
"com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt",
|
||||
// .gu
|
||||
"com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu",
|
||||
// .hk
|
||||
"com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk",
|
||||
// .id
|
||||
"ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id",
|
||||
// .il
|
||||
"ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il",
|
||||
// .in
|
||||
"4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in",
|
||||
"ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in",
|
||||
// .iq
|
||||
"com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq",
|
||||
// .ir
|
||||
"ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir",
|
||||
// .it
|
||||
"edu.it", "gov.it",
|
||||
// .je
|
||||
"co.je", "net.je", "org.je",
|
||||
// .jo
|
||||
"com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo",
|
||||
// .jp
|
||||
"ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp",
|
||||
// .ke
|
||||
"ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke",
|
||||
// .kh
|
||||
"com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh",
|
||||
// .ki
|
||||
"biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki",
|
||||
"org.ki", "tel.ki",
|
||||
// .km
|
||||
"asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km",
|
||||
"nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km",
|
||||
// .kn
|
||||
"edu.kn", "gov.kn", "net.kn", "org.kn",
|
||||
// .kr
|
||||
"ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr",
|
||||
"daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr",
|
||||
"gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr",
|
||||
"jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr",
|
||||
"sc.kr", "seoul.kr", "ulsan.kr",
|
||||
// .kw
|
||||
"com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw",
|
||||
// .ky
|
||||
"com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky",
|
||||
// .kz
|
||||
"com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz",
|
||||
// .lb
|
||||
"com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb",
|
||||
// .lk
|
||||
"assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk",
|
||||
"net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk",
|
||||
// .lr
|
||||
"com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr",
|
||||
// .lv
|
||||
"asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv",
|
||||
// .ly
|
||||
"com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly",
|
||||
// .ma
|
||||
"ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma",
|
||||
// .mc
|
||||
"asso.mc", "tm.mc",
|
||||
// .me
|
||||
"ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me",
|
||||
// .mg
|
||||
"com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg",
|
||||
// .mk
|
||||
"com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk",
|
||||
// .ml
|
||||
"com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml",
|
||||
// .mn
|
||||
"edu.mn", "gov.mn", "org.mn",
|
||||
// .mo
|
||||
"com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo",
|
||||
// .mt
|
||||
"com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt",
|
||||
// .mu
|
||||
"ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu",
|
||||
// .mv
|
||||
"aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv",
|
||||
"int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv",
|
||||
// .mw
|
||||
"ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw",
|
||||
"museum.mw", "net.mw", "org.mw",
|
||||
// .mx
|
||||
"com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx",
|
||||
// .my
|
||||
"com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my",
|
||||
// .mz
|
||||
"ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz",
|
||||
// .na
|
||||
"co.na", "com.na",
|
||||
// .nf
|
||||
"arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf",
|
||||
"rec.nf", "store.nf", "web.nf",
|
||||
// .ng
|
||||
"biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng",
|
||||
"net.ng", "org.ng", "sch.ng",
|
||||
// .ni
|
||||
"ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni",
|
||||
// .np
|
||||
"com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np",
|
||||
// .nr
|
||||
"biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr",
|
||||
// .nz
|
||||
"ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz",
|
||||
"iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz",
|
||||
// .om
|
||||
"ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om",
|
||||
"museum.om", "net.om", "org.om", "pro.om", "sch.om",
|
||||
// .pa
|
||||
"abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa",
|
||||
"nom.pa", "org.pa", "sld.pa",
|
||||
// .pe
|
||||
"com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe",
|
||||
// .ph
|
||||
"com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph",
|
||||
// .pk
|
||||
"biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk",
|
||||
"gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk",
|
||||
// .pl
|
||||
"art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl",
|
||||
"gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl",
|
||||
"lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl",
|
||||
"pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl",
|
||||
"waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl",
|
||||
// .pr
|
||||
"ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr",
|
||||
"name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr",
|
||||
// .ps
|
||||
"com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps",
|
||||
// .pt
|
||||
"com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt",
|
||||
// .pw
|
||||
"belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw",
|
||||
// .py
|
||||
"com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py",
|
||||
// .qa
|
||||
"com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa",
|
||||
// .re
|
||||
"asso.re", "com.re", "nom.re",
|
||||
// .ro
|
||||
"arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro",
|
||||
"rec.ro", "store.ro", "tm.ro", "www.ro",
|
||||
// .rs
|
||||
"ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs",
|
||||
// .ru
|
||||
"ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru",
|
||||
"bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru",
|
||||
"chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru",
|
||||
"dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru",
|
||||
"irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru",
|
||||
"kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru",
|
||||
"kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru",
|
||||
"koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru",
|
||||
"kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru",
|
||||
"mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru",
|
||||
"net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru",
|
||||
"orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru",
|
||||
"ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru",
|
||||
"simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru",
|
||||
"tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru",
|
||||
"tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru",
|
||||
"ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru",
|
||||
"vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru",
|
||||
"yekaterinburg.ru", "yuzhno-sakhalinsk.ru",
|
||||
// .rw
|
||||
"ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw",
|
||||
// .sa
|
||||
"com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa",
|
||||
// .sb
|
||||
"com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb",
|
||||
// .sc
|
||||
"com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc",
|
||||
// .sd
|
||||
"com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd",
|
||||
// .se
|
||||
"a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se",
|
||||
"h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se",
|
||||
"parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se",
|
||||
"w.se", "x.se", "y.se", "z.se",
|
||||
// .sg
|
||||
"com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg",
|
||||
// .sh
|
||||
"co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh",
|
||||
// .sl
|
||||
"com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl",
|
||||
// .sn
|
||||
"art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn",
|
||||
// .st
|
||||
"co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st",
|
||||
"net.st", "org.st", "principe.st", "saotome.st", "store.st",
|
||||
// .sv
|
||||
"com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv",
|
||||
// .sy
|
||||
"com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy",
|
||||
// .sz
|
||||
"ac.sz", "co.sz", "org.sz",
|
||||
// .th
|
||||
"ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th",
|
||||
// .tj
|
||||
"ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj",
|
||||
"int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj",
|
||||
// .tn
|
||||
"agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn",
|
||||
"ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn",
|
||||
"perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn",
|
||||
// .tr
|
||||
"av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr",
|
||||
"gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr",
|
||||
"tel.tr", "tsk.tr", "tv.tr", "web.tr",
|
||||
// .tt
|
||||
"aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt",
|
||||
"info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt",
|
||||
"net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt",
|
||||
// .tw
|
||||
"club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw",
|
||||
"mil.tw", "net.tw", "org.tw",
|
||||
// .tz
|
||||
"ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz",
|
||||
// .ua
|
||||
"biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua",
|
||||
"co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua",
|
||||
"donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua",
|
||||
"ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua",
|
||||
"kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua",
|
||||
"lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua",
|
||||
"nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua",
|
||||
"rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua",
|
||||
"uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua",
|
||||
"zp.ua", "zt.ua",
|
||||
// .ug
|
||||
"ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug",
|
||||
// .uk
|
||||
"ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk",
|
||||
"icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk",
|
||||
"national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk",
|
||||
"nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk",
|
||||
"sch.uk", "scot.uk", "soc.uk",
|
||||
// .us
|
||||
"4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us",
|
||||
// .uy
|
||||
"com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy",
|
||||
// .ve
|
||||
"co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve",
|
||||
// .vi
|
||||
"co.vi", "com.vi", "k12.vi", "net.vi", "org.vi",
|
||||
// .vn
|
||||
"ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn",
|
||||
"int.vn", "name.vn", "net.vn", "org.vn", "pro.vn",
|
||||
// .ye
|
||||
"co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye",
|
||||
// .yu
|
||||
"ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu",
|
||||
// .za
|
||||
"ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za",
|
||||
"db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za",
|
||||
"landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za",
|
||||
"olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za",
|
||||
// .zm
|
||||
"ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm",
|
||||
]);
|
||||
|
||||
function getRootDomain(domain: string): string {
|
||||
const parts = domain.split(".");
|
||||
if (parts.length <= 2) return domain;
|
||||
const lastTwo = parts.slice(-2).join(".");
|
||||
if (MULTI_PART_TLDS.has(lastTwo)) {
|
||||
return parts.length >= 3 ? parts.slice(-3).join(".") : domain;
|
||||
}
|
||||
return parts.slice(-2).join(".");
|
||||
}
|
||||
|
||||
function evictOldest() {
|
||||
if (cache.size < CACHE_MAX_SIZE) return;
|
||||
// Evict the oldest entry
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.fetchedAt < oldestTime) {
|
||||
oldestTime = entry.fetchedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey) cache.delete(oldestKey);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const domain = request.nextUrl.searchParams.get('domain');
|
||||
|
||||
if (!domain || !isValidDomain(domain)) {
|
||||
return new NextResponse(null, {
|
||||
status: 400,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve to root domain so subdomains share the same favicon lookup
|
||||
const normalizedDomain = getRootDomain(domain.toLowerCase());
|
||||
|
||||
// Check negative cache (domains known to have no favicon)
|
||||
const neg = negativeCache.get(normalizedDomain);
|
||||
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
|
||||
});
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cached = cache.get(normalizedDomain);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return new NextResponse(cached.data, {
|
||||
headers: {
|
||||
'Content-Type': cached.contentType,
|
||||
'Cache-Control': 'public, max-age=1209600', // 2 weeks
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
`https://icons.duckduckgo.com/ip3/${encodeURIComponent(normalizedDomain)}.ico`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
|
||||
if (!upstream.ok) {
|
||||
evictNegativeOldest();
|
||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
||||
const data = await upstream.arrayBuffer();
|
||||
|
||||
// Don't cache empty/tiny responses (likely no real favicon)
|
||||
if (data.byteLength < 10) {
|
||||
evictNegativeOldest();
|
||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
evictOldest();
|
||||
cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() });
|
||||
|
||||
return new NextResponse(data, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=1209600',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new NextResponse(null, {
|
||||
status: 502,
|
||||
headers: { 'Cache-Control': 'public, max-age=300' }, // 5 min
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function evictNegativeOldest() {
|
||||
if (negativeCache.size < NEGATIVE_CACHE_MAX_SIZE) return;
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, entry] of negativeCache) {
|
||||
if (entry.fetchedAt < oldestTime) {
|
||||
oldestTime = entry.fetchedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey) negativeCache.delete(oldestKey);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { url?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { url } = body;
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await isPublicHttpUrl(url))) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
if (!(await isPublicHttpUrl(currentUrl))) {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||
}
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength > MAX_RESPONSE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar',
|
||||
'Content-Length': buffer.byteLength.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
+26
-35
@@ -1,10 +1,21 @@
|
||||
import v8 from 'node:v8';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Health check thresholds
|
||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
||||
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
|
||||
const MEMORY_WARNING_THRESHOLD = 0.85;
|
||||
const MEMORY_CRITICAL_THRESHOLD = 0.95;
|
||||
|
||||
function getHeapUsagePercent(heapUsed: number, heapTotal: number): number {
|
||||
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||
const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal;
|
||||
|
||||
if (denominator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (heapUsed / denominator) * 100;
|
||||
}
|
||||
|
||||
interface HealthStatus {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
@@ -14,6 +25,7 @@ interface HealthStatus {
|
||||
memory?: {
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
heapSizeLimit: number;
|
||||
rss: number;
|
||||
external: number;
|
||||
heapUsagePercent: number;
|
||||
@@ -27,14 +39,9 @@ interface HealthStatus {
|
||||
/**
|
||||
* Health check endpoint for container orchestration
|
||||
*
|
||||
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
|
||||
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
|
||||
* HEAD /api/health - Lightweight health check (status code only)
|
||||
*
|
||||
* Health status based on Node.js heap usage:
|
||||
* - Healthy (200): < 85% heap usage
|
||||
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
|
||||
* - Unhealthy (503): > 95% heap usage
|
||||
* GET /api/health - Liveness probe for container orchestration
|
||||
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings
|
||||
* HEAD /api/health - Lightweight liveness probe (status code only)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
||||
|
||||
// Determine health status based on memory usage
|
||||
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
|
||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||
const warnings: string[] = [];
|
||||
let httpStatus = 200;
|
||||
|
||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
||||
status = 'unhealthy';
|
||||
httpStatus = 503;
|
||||
status = 'degraded';
|
||||
warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
||||
status = 'degraded';
|
||||
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`);
|
||||
warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||
}
|
||||
|
||||
// Build response
|
||||
const response: HealthStatus = {
|
||||
status,
|
||||
status: detailed ? status : 'healthy',
|
||||
timestamp,
|
||||
};
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// Add detailed information if requested
|
||||
if (detailed) {
|
||||
response.uptime = process.uptime();
|
||||
response.version = process.env.npm_package_version || '0.1.0';
|
||||
response.memory = {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
heapSizeLimit,
|
||||
rss: memUsage.rss,
|
||||
external: memUsage.external,
|
||||
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
||||
@@ -87,10 +87,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Health check', { status, detailed });
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: httpStatus,
|
||||
status: 200,
|
||||
headers: {
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
|
||||
*/
|
||||
export async function HEAD() {
|
||||
try {
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
||||
|
||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
} catch {
|
||||
return new Response(null, { status: 503 });
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||
*
|
||||
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||
* No admin auth required - this is how regular users receive plugins/themes.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
listDevPlugins(),
|
||||
]);
|
||||
|
||||
// Dev plugins win on id collision so a developer can shadow an installed
|
||||
// plugin without uninstalling it first.
|
||||
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||
const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id));
|
||||
|
||||
const plugins = [
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true })),
|
||||
...installedEnabled.map(p => ({ ...p, dev: false })),
|
||||
].map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
author: p.author,
|
||||
description: p.description,
|
||||
type: p.type,
|
||||
permissions: p.permissions,
|
||||
entrypoint: p.entrypoint,
|
||||
forceEnabled: p.forceEnabled || false,
|
||||
// Content hash + updatedAt let clients detect re-uploads even when
|
||||
// the manifest version is unchanged.
|
||||
bundleHash: p.bundleHash,
|
||||
updatedAt: p.updatedAt,
|
||||
// Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge.
|
||||
dev: p.dev,
|
||||
// Surface so clients can enforce api.http.fetch origin allowlists.
|
||||
httpOrigins: p.httpOrigins,
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
}));
|
||||
|
||||
// Only serve enabled themes
|
||||
const themes = themeRegistry.themes
|
||||
.filter(t => t.enabled)
|
||||
.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
version: t.version,
|
||||
author: t.author,
|
||||
description: t.description,
|
||||
variants: t.variants,
|
||||
forceEnabled: t.forceEnabled || false,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{ plugins, themes },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/push/preview
|
||||
*
|
||||
* Called from the service worker when a Web Push wake-up arrives. Fetches the
|
||||
* latest unread email so the SW can build an enriched system notification
|
||||
* (sender, subject, avatar) without ever exposing JMAP credentials to the
|
||||
* SW context.
|
||||
*
|
||||
* The relay's push payload is intentionally minimal (just a state-change
|
||||
* ping), so this is what makes "From: Alice / Subject: …" appear instead of
|
||||
* a generic "New mail" string.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: creds.authHeader },
|
||||
});
|
||||
if (!sessionRes.ok) {
|
||||
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
|
||||
}
|
||||
const session = (await sessionRes.json()) as {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
};
|
||||
const apiUrl = session.apiUrl;
|
||||
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (!apiUrl || !accountId) {
|
||||
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
||||
}
|
||||
|
||||
const inboxRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||
methodCalls: [
|
||||
[
|
||||
'Mailbox/query',
|
||||
{ accountId, filter: { role: 'inbox' }, limit: 1 },
|
||||
'mb',
|
||||
],
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!inboxRes.ok) {
|
||||
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
const inboxData = (await inboxRes.json()) as {
|
||||
methodResponses: [string, Record<string, unknown>, string][];
|
||||
};
|
||||
|
||||
const inboxBody = inboxData.methodResponses.find(
|
||||
([method]) => method === 'Mailbox/query',
|
||||
)?.[1] as { ids?: string[] } | undefined;
|
||||
|
||||
const inboxId = inboxBody?.ids?.[0];
|
||||
|
||||
if (!inboxId) {
|
||||
return NextResponse.json({
|
||||
email: null,
|
||||
unreadTotal: 0,
|
||||
}, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Pull the most recent unread message from the resolved Inbox mailbox.
|
||||
const requestBody = {
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||
methodCalls: [
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
accountId,
|
||||
filter: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ inMailbox: inboxId },
|
||||
{ notKeyword: '$seen' },
|
||||
],
|
||||
},
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit: 1,
|
||||
calculateTotal: true,
|
||||
},
|
||||
'eq',
|
||||
],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'],
|
||||
},
|
||||
'eg',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
const jmapRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
if (!jmapRes.ok) {
|
||||
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
|
||||
}
|
||||
const data = (await jmapRes.json()) as {
|
||||
methodResponses: [string, Record<string, unknown>, string][];
|
||||
};
|
||||
|
||||
type EmailLite = {
|
||||
id: string;
|
||||
threadId: string;
|
||||
from?: { name?: string | null; email?: string }[] | null;
|
||||
subject?: string | null;
|
||||
preview?: string | null;
|
||||
receivedAt?: string | null;
|
||||
};
|
||||
|
||||
let email: EmailLite | null = null;
|
||||
let unreadTotal = 0;
|
||||
for (const [method, body] of data.methodResponses) {
|
||||
if (method === 'Email/query') {
|
||||
unreadTotal = ((body as { total?: number }).total) ?? 0;
|
||||
}
|
||||
if (method === 'Email/get') {
|
||||
const list = (body as { list?: EmailLite[] }).list ?? [];
|
||||
email = list[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
email,
|
||||
unreadTotal,
|
||||
}, {
|
||||
headers: {
|
||||
// SW already gates on its own logic - don't let push events get
|
||||
// cached and served stale.
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// `fetch failed` from undici is too generic to debug - the real reason
|
||||
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
|
||||
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||
logger.error('push preview failed', {
|
||||
error: err?.message ?? 'Unknown error',
|
||||
causeCode: err?.cause?.code,
|
||||
causeMessage: err?.cause?.message,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
// Cache resized images in memory to avoid reprocessing on every request
|
||||
const cache = new Map<number, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
// Absolute URL (http/https)
|
||||
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||
const res = await fetch(iconUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ size: string }> }
|
||||
) {
|
||||
const { size: sizeParam } = await params;
|
||||
const size = parseInt(sizeParam, 10);
|
||||
|
||||
if (!VALID_SIZES.has(size)) {
|
||||
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||
}
|
||||
|
||||
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
||||
if (!iconUrl) {
|
||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||
}
|
||||
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
};
|
||||
|
||||
try {
|
||||
if (cache.has(size)) {
|
||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||
const resized = await sharp(sourceBuffer)
|
||||
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(size, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
console.error('Failed to generate PWA icon:', err);
|
||||
return new NextResponse('Failed to generate icon', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
function classifyError(error: unknown): { message: string; status: number } {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
switch (code) {
|
||||
case 'EACCES':
|
||||
case 'EPERM':
|
||||
return {
|
||||
message: 'Write permission denied on settings data directory. Check filesystem permissions for the SETTINGS_DATA_DIR (or data/settings/).',
|
||||
status: 500,
|
||||
};
|
||||
case 'EROFS':
|
||||
return {
|
||||
message: 'Filesystem is read-only. Settings cannot be saved. Ensure the data directory is on a writable volume.',
|
||||
status: 500,
|
||||
};
|
||||
case 'ENOSPC':
|
||||
return {
|
||||
message: 'No disk space available to save settings.',
|
||||
status: 507,
|
||||
};
|
||||
case 'ENOENT':
|
||||
return {
|
||||
message: 'Settings data directory does not exist and could not be created. Check SETTINGS_DATA_DIR configuration.',
|
||||
status: 500,
|
||||
};
|
||||
default:
|
||||
if (msg.includes('SESSION_SECRET')) {
|
||||
return {
|
||||
message: 'Server configuration error: SESSION_SECRET is not set.',
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: `Internal server error: ${msg}`,
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabled(): boolean {
|
||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
|
||||
}
|
||||
|
||||
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify identity against session cookies across all account slots.
|
||||
* With multi-account, the requesting account may be on any slot (0-4).
|
||||
* Checks both basic-auth session cookies and stalwart auth context cookies
|
||||
* (used by OAuth/SSO and TOTP-upgraded sessions).
|
||||
* Returns true only if a matching cookie is found.
|
||||
*/
|
||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
const normalizedServerUrl = normalizeUrl(serverUrl);
|
||||
|
||||
for (let slot = 0; slot <= 4; slot++) {
|
||||
// Check basic-auth session cookie
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (token) {
|
||||
const session = decryptSession(token);
|
||||
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check stalwart auth context cookie (set for all auth modes)
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No matching session found (or no cookies at all) → reject
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isEnabled()) {
|
||||
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
|
||||
}
|
||||
|
||||
const username = request.headers.get('x-settings-username');
|
||||
const serverUrl = request.headers.get('x-settings-server');
|
||||
if (!username || !serverUrl) {
|
||||
return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await verifyIdentity(username, serverUrl))) {
|
||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await loadUserSettings(username, serverUrl);
|
||||
return NextResponse.json({ settings: settings || null });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
logger.error('Settings load error', { error: message, code });
|
||||
const classified = classifyError(error);
|
||||
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isEnabled()) {
|
||||
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { username, serverUrl, settings } = await request.json();
|
||||
if (!username || !serverUrl || !settings) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
|
||||
return NextResponse.json({ error: 'Settings must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await verifyIdentity(username, serverUrl))) {
|
||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Enforce admin policy - strip locked settings so users can't override them
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const filteredSettings = { ...settings };
|
||||
for (const key of Object.keys(filteredSettings)) {
|
||||
const restriction = policy.restrictions[key];
|
||||
if (!restriction) continue;
|
||||
if (restriction.locked) {
|
||||
delete filteredSettings[key];
|
||||
continue;
|
||||
}
|
||||
const value = filteredSettings[key];
|
||||
if (restriction.allowedValues && restriction.allowedValues.length > 0) {
|
||||
if (!restriction.allowedValues.includes(value)) {
|
||||
delete filteredSettings[key];
|
||||
}
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (restriction.min !== undefined && value < restriction.min) {
|
||||
delete filteredSettings[key];
|
||||
}
|
||||
if (restriction.max !== undefined && value > restriction.max) {
|
||||
delete filteredSettings[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await saveUserSettings(username, serverUrl, filteredSettings);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
logger.error('Settings save error', { error: message, code });
|
||||
const classified = classifyError(error);
|
||||
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
if (!isEnabled()) {
|
||||
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { username, serverUrl } = await request.json();
|
||||
if (!username || !serverUrl) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await verifyIdentity(username, serverUrl))) {
|
||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||
}
|
||||
|
||||
await deleteUserSettings(username, serverUrl);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
logger.error('Settings delete error', { error: message, code });
|
||||
const classified = classifyError(error);
|
||||
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkOnce, loadState } from '@/lib/version-check';
|
||||
|
||||
// Public endpoint that returns the latest cached update status. Fed by the
|
||||
// background scheduler started in instrumentation.node.ts; in production we
|
||||
// never trigger a fresh upstream fetch from this route so an unauthenticated
|
||||
// client can't use it to amplify traffic to the version server.
|
||||
//
|
||||
// In development we force a fresh fetch on every hit so changes to the
|
||||
// version server's overrides take effect on the next page reload instead of
|
||||
// requiring a dev-server restart. The 5s upstream timeout in fetchStatus
|
||||
// caps the worst-case latency added to a dev reload.
|
||||
export async function GET() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
await checkOnce({ reason: 'dev-reload' });
|
||||
}
|
||||
|
||||
const state = await loadState();
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: state.status,
|
||||
lastCheckedAt: state.lastCheckedAt,
|
||||
lastSuccessAt: state.lastSuccessAt,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
||||
|
||||
function normalizeDavRelativePath(rawPath: string): string {
|
||||
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
|
||||
const segments = sanitized.split('/').filter(Boolean);
|
||||
|
||||
return segments.map((segment) => {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
throw new Error('Invalid WebDAV path encoding');
|
||||
}
|
||||
|
||||
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
|
||||
throw new Error('Invalid WebDAV path segment');
|
||||
}
|
||||
|
||||
return encodeURIComponent(decoded);
|
||||
}).join('/');
|
||||
}
|
||||
|
||||
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
|
||||
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
|
||||
const relativePath = normalizeDavRelativePath(rawPath);
|
||||
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webdav
|
||||
* Proxies WebDAV requests to the Stalwart server.
|
||||
*
|
||||
* Headers:
|
||||
* X-WebDAV-Method: The actual WebDAV method (PROPFIND, MKCOL, GET, PUT, DELETE, MOVE, COPY)
|
||||
* X-WebDAV-Path: Resource path relative to the user's DAV root (default: /)
|
||||
* X-WebDAV-Destination: Destination path for MOVE/COPY (relative to user's DAV root)
|
||||
* Depth: WebDAV Depth header (forwarded as-is)
|
||||
* Content-Type: Forwarded for PROPFIND (XML) and PUT (file upload)
|
||||
* Overwrite: WebDAV Overwrite header for MOVE/COPY
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const method = request.headers.get('X-WebDAV-Method')?.toUpperCase();
|
||||
if (!method || !ALLOWED_METHODS.has(method)) {
|
||||
return NextResponse.json({ error: 'Invalid WebDAV method' }, { status: 400 });
|
||||
}
|
||||
|
||||
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
||||
const baseUrl = creds.serverUrl.replace(/\/$/, '');
|
||||
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||
|
||||
// Build headers for the upstream request
|
||||
const upstreamHeaders: Record<string, string> = {
|
||||
'Authorization': creds.authHeader,
|
||||
};
|
||||
|
||||
// Forward relevant WebDAV headers
|
||||
const depth = request.headers.get('Depth');
|
||||
if (depth) upstreamHeaders['Depth'] = depth;
|
||||
|
||||
const contentType = request.headers.get('Content-Type');
|
||||
if (contentType) upstreamHeaders['Content-Type'] = contentType;
|
||||
|
||||
// For MOVE/COPY, construct the full Destination URL from the relative path
|
||||
const destination = request.headers.get('X-WebDAV-Destination');
|
||||
if (destination) {
|
||||
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
|
||||
}
|
||||
|
||||
const overwrite = request.headers.get('Overwrite');
|
||||
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
||||
|
||||
// Forward request body for methods that need it.
|
||||
// PUT streams directly to upstream to avoid buffering large uploads in memory.
|
||||
// PROPFIND bodies are small XML and are read fully.
|
||||
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
|
||||
if (method === 'PROPFIND') {
|
||||
body = await request.arrayBuffer();
|
||||
} else if (method === 'PUT') {
|
||||
body = request.body;
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method,
|
||||
headers: upstreamHeaders,
|
||||
body,
|
||||
redirect: 'follow',
|
||||
// `duplex: 'half'` is required by undici when sending a streaming request body.
|
||||
...(method === 'PUT' ? { duplex: 'half' } : {}),
|
||||
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
|
||||
|
||||
// For file downloads (GET), stream the response back
|
||||
if (method === 'GET') {
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength) headers.set('Content-Length', contentLength);
|
||||
headers.set('X-WebDAV-Request-URI', targetUrl);
|
||||
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// For PROPFIND, return XML with the actual request URI for href comparison
|
||||
if (method === 'PROPFIND') {
|
||||
const text = await response.text();
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'application/xml; charset=utf-8');
|
||||
headers.set('X-WebDAV-Request-URI', targetUrl);
|
||||
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// For other methods (MKCOL, DELETE, MOVE, COPY, PUT), return the status
|
||||
return new NextResponse(null, {
|
||||
status: response.status,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+450
-62
@@ -3,6 +3,8 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
font-size: var(--font-size-base);
|
||||
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #94a3b8;
|
||||
@@ -16,11 +18,55 @@
|
||||
--color-muted-foreground: #64748b;
|
||||
--color-accent: #dbeafe;
|
||||
--color-accent-foreground: #1e40af;
|
||||
--color-destructive: #ef4444;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #0f172a;
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: #f8fafc;
|
||||
--color-sidebar-foreground: #0f172a;
|
||||
--color-sidebar-border: #e2e8f0;
|
||||
--color-sidebar-accent: #f1f5f9;
|
||||
--color-sidebar-accent-foreground: #0f172a;
|
||||
|
||||
/* Card */
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #0f172a;
|
||||
|
||||
/* Semantic status colors */
|
||||
--color-success: #22c55e;
|
||||
--color-success-foreground: #ffffff;
|
||||
--color-warning: #eab308;
|
||||
--color-warning-foreground: #ffffff;
|
||||
--color-info: #3b82f6;
|
||||
--color-info-foreground: #ffffff;
|
||||
|
||||
/* Selection states */
|
||||
--color-selection: #dbeafe;
|
||||
--color-selection-foreground: #1e40af;
|
||||
|
||||
/* Unread indicator */
|
||||
--color-unread: #3b82f6;
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: #3b82f6;
|
||||
--color-chart-2: #22c55e;
|
||||
--color-chart-3: #f59e0b;
|
||||
--color-chart-4: #ef4444;
|
||||
--color-chart-5: #8b5cf6;
|
||||
|
||||
/* Settings variables */
|
||||
--font-size-base: 16px;
|
||||
--list-item-height: 48px;
|
||||
--transition-duration: 0.2s;
|
||||
|
||||
/* Density spacing variables (defaults = regular) */
|
||||
--density-item-py: 12px;
|
||||
--density-item-gap: 12px;
|
||||
--density-header-py: 12px;
|
||||
--density-card-p: 16px;
|
||||
--density-sidebar-py: 4px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -37,6 +83,43 @@
|
||||
--color-muted-foreground: #a3a3a3;
|
||||
--color-accent: #1e3a8a;
|
||||
--color-accent-foreground: #dbeafe;
|
||||
--color-destructive: #ef4444;
|
||||
--color-destructive-foreground: #fafafa;
|
||||
--color-popover: #1c1c1c;
|
||||
--color-popover-foreground: #fafafa;
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: #0a0a0a;
|
||||
--color-sidebar-foreground: #fafafa;
|
||||
--color-sidebar-border: #262626;
|
||||
--color-sidebar-accent: #1a1a1a;
|
||||
--color-sidebar-accent-foreground: #fafafa;
|
||||
|
||||
/* Card */
|
||||
--color-card: #141414;
|
||||
--color-card-foreground: #fafafa;
|
||||
|
||||
/* Semantic status colors */
|
||||
--color-success: #16a34a;
|
||||
--color-success-foreground: #ffffff;
|
||||
--color-warning: #ca8a04;
|
||||
--color-warning-foreground: #ffffff;
|
||||
--color-info: #60a5fa;
|
||||
--color-info-foreground: #ffffff;
|
||||
|
||||
/* Selection states */
|
||||
--color-selection: rgba(59, 130, 246, 0.25);
|
||||
--color-selection-foreground: #93c5fd;
|
||||
|
||||
/* Unread indicator */
|
||||
--color-unread: #60a5fa;
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: #60a5fa;
|
||||
--color-chart-2: #4ade80;
|
||||
--color-chart-3: #fbbf24;
|
||||
--color-chart-4: #f87171;
|
||||
--color-chart-5: #a78bfa;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -53,6 +136,31 @@
|
||||
--color-muted-foreground: var(--color-muted-foreground);
|
||||
--color-accent: var(--color-accent);
|
||||
--color-accent-foreground: var(--color-accent-foreground);
|
||||
--color-destructive: var(--color-destructive);
|
||||
--color-destructive-foreground: var(--color-destructive-foreground);
|
||||
--color-popover: var(--color-popover);
|
||||
--color-popover-foreground: var(--color-popover-foreground);
|
||||
--color-sidebar: var(--color-sidebar);
|
||||
--color-sidebar-foreground: var(--color-sidebar-foreground);
|
||||
--color-sidebar-border: var(--color-sidebar-border);
|
||||
--color-sidebar-accent: var(--color-sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground);
|
||||
--color-card: var(--color-card);
|
||||
--color-card-foreground: var(--color-card-foreground);
|
||||
--color-success: var(--color-success);
|
||||
--color-success-foreground: var(--color-success-foreground);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-warning-foreground: var(--color-warning-foreground);
|
||||
--color-info: var(--color-info);
|
||||
--color-info-foreground: var(--color-info-foreground);
|
||||
--color-selection: var(--color-selection);
|
||||
--color-selection-foreground: var(--color-selection-foreground);
|
||||
--color-unread: var(--color-unread);
|
||||
--color-chart-1: var(--color-chart-1);
|
||||
--color-chart-2: var(--color-chart-2);
|
||||
--color-chart-3: var(--color-chart-3);
|
||||
--color-chart-4: var(--color-chart-4);
|
||||
--color-chart-5: var(--color-chart-5);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -62,9 +170,13 @@
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: var(--font-size-base);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
|
||||
/* Minimalist scrollbar */
|
||||
@@ -92,14 +204,60 @@ body {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* Forces light-theme CSS variables inside the email content area, so when
|
||||
"Always Show Emails in Light Mode" is enabled in dark theme the surrounding
|
||||
sender info / attachments / plain-text body don't end up with light text
|
||||
on a white background. */
|
||||
.email-content-light {
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0f172a;
|
||||
--color-muted: #f1f5f9;
|
||||
--color-muted-foreground: #64748b;
|
||||
--color-border: #e2e8f0;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #0f172a;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #0f172a;
|
||||
--color-secondary: #f8fafc;
|
||||
--color-secondary-foreground: #0f172a;
|
||||
--color-accent: #dbeafe;
|
||||
--color-accent-foreground: #1e40af;
|
||||
--color-input: #e2e8f0;
|
||||
}
|
||||
|
||||
.email-content-light .email-content-text a {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.email-content-text {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.email-content-text a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dark .email-content-text a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, sans-serif;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground);
|
||||
max-width: none;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Remove padding for rich HTML emails with table-based layouts */
|
||||
.email-content:has(> table) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.email-content p {
|
||||
@@ -126,46 +284,31 @@ body {
|
||||
}
|
||||
|
||||
.email-content blockquote {
|
||||
border-left: 3px solid #d1d5db;
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #4b5563;
|
||||
color: var(--color-muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dark .email-content blockquote {
|
||||
border-left-color: #4b5563;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.email-content pre {
|
||||
background-color: #f5f5f7;
|
||||
border: 1px solid #e5e7eb;
|
||||
background-color: var(--color-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
|
||||
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.dark .email-content pre {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.email-content code {
|
||||
background-color: #f3f4f6;
|
||||
background-color: var(--color-muted);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
|
||||
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.dark .email-content code {
|
||||
background-color: #374151;
|
||||
color: #f87171;
|
||||
color: var(--color-destructive);
|
||||
}
|
||||
|
||||
.email-content h1,
|
||||
@@ -179,10 +322,18 @@ body {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.email-content h1 { font-size: 1.5rem; }
|
||||
.email-content h2 { font-size: 1.25rem; }
|
||||
.email-content h3 { font-size: 1.125rem; }
|
||||
.email-content h4 { font-size: 1rem; }
|
||||
.email-content h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.email-content h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.email-content h3 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.email-content h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.email-content ul,
|
||||
.email-content ol {
|
||||
@@ -203,6 +354,26 @@ body {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
/* Reply quoted HTML - preserves original inline styles/colors */
|
||||
.email-reply-quote {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.email-reply-quote p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.email-reply-quote img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.email-reply-quote a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Only style tables that are actual data tables, not layout tables */
|
||||
.email-content table.data-table,
|
||||
.email-content table[border="1"] {
|
||||
@@ -217,26 +388,15 @@ body {
|
||||
.email-content table[border="1"] th,
|
||||
.email-content table[border="1"] td {
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.email-content table.data-table th,
|
||||
.email-content table[border="1"] th {
|
||||
background-color: #f9fafb;
|
||||
background-color: var(--color-muted);
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .email-content table.data-table th,
|
||||
.dark .email-content table[border="1"] th {
|
||||
background-color: #1f2937;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.dark .email-content table.data-table td,
|
||||
.dark .email-content table[border="1"] td {
|
||||
border-color: #374151;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Reset styles for layout tables (commonly used in HTML emails) */
|
||||
@@ -258,32 +418,23 @@ body {
|
||||
|
||||
.email-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.dark .email-content hr {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
/* Email thread quoted text */
|
||||
.email-content .quoted-text {
|
||||
border-left: 3px solid #d1d5db;
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #4b5563;
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dark .email-content .quoted-text {
|
||||
border-left-color: #4b5563;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
@keyframes slide-in {
|
||||
@keyframes toast-enter {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
transform: translateX(calc(100% + 1.25rem));
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
@@ -292,10 +443,33 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
@keyframes toast-exit {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(100% + 1.25rem));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-progress {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-enter {
|
||||
animation: toast-enter 0.32s cubic-bezier(0.21, 1.02, 0.73, 1) forwards;
|
||||
}
|
||||
|
||||
.toast-exit {
|
||||
animation: toast-exit 0.28s cubic-bezier(0.06, 0.71, 0.55, 1) forwards;
|
||||
}
|
||||
|
||||
/* Mobile Responsive Utilities */
|
||||
|
||||
@@ -331,7 +505,9 @@ body {
|
||||
|
||||
/* Smooth transitions for view switching */
|
||||
.view-transition {
|
||||
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
transition:
|
||||
transform 0.3s ease-in-out,
|
||||
opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Hide scrollbar on mobile while keeping functionality */
|
||||
@@ -346,6 +522,15 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scroll-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Mobile backdrop blur support */
|
||||
@supports (backdrop-filter: blur(8px)) {
|
||||
.mobile-backdrop {
|
||||
@@ -387,6 +572,37 @@ body {
|
||||
animation: slide-in-from-left 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Calendar swipe animations (subtler slide for view transitions) */
|
||||
@keyframes cal-slide-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cal-slide-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: cal-slide-in-right 0.25s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: cal-slide-in-left 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Reduced motion: respect user OS preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
@@ -406,3 +622,175 @@ body {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* TipTap Rich Text Editor */
|
||||
.tiptap {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tiptap p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap li {
|
||||
margin: 0.125rem 0;
|
||||
}
|
||||
|
||||
.tiptap blockquote {
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding-left: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.tiptap pre {
|
||||
background-color: var(--color-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap code {
|
||||
background-color: var(--color-muted);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tiptap a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tiptap img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--color-muted-foreground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.tiptap .ProseMirror-selectednode img {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tiptap table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.5rem 0;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tiptap table td,
|
||||
.tiptap table th {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.375rem 0.5rem;
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
min-width: 1em;
|
||||
}
|
||||
|
||||
.tiptap table th {
|
||||
background-color: var(--color-muted) !important;
|
||||
color: var(--color-foreground) !important;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tiptap table p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tiptap table .selectedCell::after {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
content: "";
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tiptap table .column-resize-handle {
|
||||
background-color: var(--color-primary);
|
||||
bottom: -2px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.tiptap.resize-cursor {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Smooth halo applied to a setting row when the user clicks a sub-result in
|
||||
the settings search. The element bg + a 10px box-shadow fill the row plus
|
||||
a 10px ring of the same tint, then a 2px outline sits exactly at the outer
|
||||
edge. Uses the primary theme color so it adapts to light/dark. */
|
||||
@keyframes settings-search-pulse {
|
||||
0% {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||
outline-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||
}
|
||||
30% {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
box-shadow: 0 0 0 10px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
outline-color: color-mix(in srgb, var(--color-primary) 60%, transparent);
|
||||
}
|
||||
100% {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||
box-shadow: 0 0 0 10px color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||
outline-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-search-highlight {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 10px;
|
||||
border-radius: 8px;
|
||||
animation: settings-search-pulse 1.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||
<defs>
|
||||
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
|
||||
<style>
|
||||
.icon-bg { fill: #ffffff; }
|
||||
.icon-mark { fill: rgb(219,45,84); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-bg { fill: #18181b; }
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
|
||||
<rect width="1000" height="1000" class="icon-bg"/>
|
||||
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
|
||||
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
+33
-4
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -14,10 +16,23 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JMAP Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const faviconUrl = process.env.FAVICON_URL;
|
||||
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
@@ -26,10 +41,22 @@ export default async function RootLayout({
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-title"
|
||||
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
{parentOrigin && (
|
||||
<meta name="parent-origin" content={parentOrigin} />
|
||||
)}
|
||||
<script
|
||||
nonce={nonce}
|
||||
suppressHydrationWarning
|
||||
@@ -54,7 +81,9 @@ export default async function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
<PWAInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Manifest paths must include the deployment subpath - browsers resolve them
|
||||
// against the document origin, not the manifest's location, and Next.js does
|
||||
// not auto-prefix string literals inside MetadataRoute payloads.
|
||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
const appName =
|
||||
process.env.APP_NAME ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
|
||||
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||
const description =
|
||||
process.env.APP_DESCRIPTION ||
|
||||
"A modern webmail client built for Stalwart Mail Server";
|
||||
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||
|
||||
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||
|
||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||
? [
|
||||
{ src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
]
|
||||
: [
|
||||
{ src: withBase("/icon-192x192.png"), sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: withBase("/icon-512x512.png"), sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: withBase("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: withBase("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
{ src: withBase("/icon-maskable-dark-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
];
|
||||
|
||||
return {
|
||||
name: appName,
|
||||
short_name: shortName,
|
||||
description,
|
||||
start_url: withBase("/"),
|
||||
scope: withBase("/"),
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
theme_color: themeColor,
|
||||
background_color: backgroundColor,
|
||||
icons,
|
||||
categories: ["productivity"],
|
||||
screenshots: [
|
||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
|
||||
export default function NotFound() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
const prefix = getPathPrefix();
|
||||
// Don't redirect admin routes to the webmail login page. Admin paths
|
||||
// are mounted relative to the deployment prefix, so account for it.
|
||||
const adminBase = `${prefix}/admin`;
|
||||
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||
if (!isAdminRoute) {
|
||||
window.location.href = `${prefix}/login`;
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
let isAdmin = false;
|
||||
if (typeof window !== 'undefined') {
|
||||
const prefix = getPathPrefix();
|
||||
const adminBase = `${prefix}/admin`;
|
||||
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||
}
|
||||
if (!isAdmin) return null;
|
||||
}
|
||||
|
||||
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
||||
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
||||
<a
|
||||
href={`${prefix}/`}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Go home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
||||
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
||||
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||
import { MapPin, Users } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventEndDate } from "@/lib/calendar-utils";
|
||||
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
|
||||
@@ -15,6 +15,9 @@ interface CalendarAgendaViewProps {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
}
|
||||
|
||||
@@ -25,9 +28,13 @@ interface DayGroup {
|
||||
}
|
||||
|
||||
export function CalendarAgendaView({
|
||||
selectedDate,
|
||||
events,
|
||||
calendars,
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
timeFormat = "24h",
|
||||
}: CalendarAgendaViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
@@ -39,9 +46,12 @@ export function CalendarAgendaView({
|
||||
return map;
|
||||
}, [calendars]);
|
||||
|
||||
const todayRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const sorted = [...events].sort((a, b) =>
|
||||
new Date(a.start).getTime() - new Date(b.start).getTime()
|
||||
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
|
||||
);
|
||||
|
||||
const groups: DayGroup[] = [];
|
||||
@@ -49,43 +59,54 @@ export function CalendarAgendaView({
|
||||
|
||||
sorted.forEach((ev) => {
|
||||
try {
|
||||
const start = new Date(ev.start);
|
||||
const end = getEventEndDate(ev);
|
||||
const startKey = format(start, "yyyy-MM-dd");
|
||||
const endKey = format(end, "yyyy-MM-dd");
|
||||
|
||||
if (startKey === endKey || ev.showWithoutTime) {
|
||||
let group = groupMap.get(startKey);
|
||||
const { startDay, endDay } = getEventDayBounds(ev);
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
const key = format(cursor, "yyyy-MM-dd");
|
||||
let group = groupMap.get(key);
|
||||
if (!group) {
|
||||
group = { date: start, dateKey: startKey, events: [] };
|
||||
groupMap.set(startKey, group);
|
||||
group = { date: new Date(cursor), dateKey: key, events: [] };
|
||||
groupMap.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.events.push(ev);
|
||||
} else {
|
||||
const cursor = new Date(start);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
const endDay = new Date(end);
|
||||
endDay.setHours(0, 0, 0, 0);
|
||||
while (cursor <= endDay) {
|
||||
const key = format(cursor, "yyyy-MM-dd");
|
||||
let group = groupMap.get(key);
|
||||
if (!group) {
|
||||
group = { date: new Date(cursor), dateKey: key, events: [] };
|
||||
groupMap.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.events.push(ev);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
} catch { /* skip invalid dates */ }
|
||||
});
|
||||
|
||||
// Always include today's date in the groups so the view has a "Today" anchor
|
||||
const todayKey = format(new Date(), "yyyy-MM-dd");
|
||||
if (!groupMap.has(todayKey)) {
|
||||
const todayGroup = { date: startOfDay(new Date()), dateKey: todayKey, events: [] as CalendarEvent[] };
|
||||
groupMap.set(todayKey, todayGroup);
|
||||
groups.push(todayGroup);
|
||||
}
|
||||
|
||||
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
return groups;
|
||||
}, [events]);
|
||||
|
||||
// Auto-scroll to today's section on mount and when selectedDate changes to today
|
||||
const scrollToToday = useCallback(() => {
|
||||
if (todayRef.current) {
|
||||
todayRef.current.scrollIntoView({ block: "start" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to today on mount
|
||||
const frame = requestAnimationFrame(scrollToToday);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [scrollToToday]);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to today when selectedDate changes to today
|
||||
if (isToday(selectedDate)) {
|
||||
scrollToToday();
|
||||
}
|
||||
}, [selectedDate, scrollToToday]);
|
||||
|
||||
const formatDateHeader = (date: Date): string => {
|
||||
if (isToday(date)) return t("events.today_header");
|
||||
if (isTomorrow(date)) return t("events.tomorrow_header");
|
||||
@@ -99,19 +120,10 @@ export function CalendarAgendaView({
|
||||
return format(date, "HH:mm");
|
||||
};
|
||||
|
||||
if (grouped.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground">
|
||||
<CalendarIcon className="w-12 h-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("events.no_events")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||
{grouped.map((group) => (
|
||||
<div key={group.dateKey}>
|
||||
<div key={group.dateKey} ref={isToday(group.date) ? todayRef : undefined}>
|
||||
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
@@ -124,14 +136,19 @@ export function CalendarAgendaView({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{group.events.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("events.no_events")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{group.events.map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const calendar = calendarMap.get(calId);
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||
const color = getEventColor(ev, calendar);
|
||||
const start = parseISO(ev.start);
|
||||
const start = getEventStartDate(ev);
|
||||
const durMin = parseDuration(ev.duration);
|
||||
const end = new Date(start.getTime() + durMin * 60000);
|
||||
const end = getEventEndDate(ev);
|
||||
const locationName = ev.locations
|
||||
? Object.values(ev.locations)[0]?.name
|
||||
: null;
|
||||
@@ -140,7 +157,11 @@ export function CalendarAgendaView({
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
||||
className="w-full flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
|
||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onHoverLeave?.()}
|
||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
||||
{ev.showWithoutTime ? (
|
||||
@@ -186,6 +207,7 @@ export function CalendarAgendaView({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,21 +2,31 @@
|
||||
|
||||
import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, isToday, parseISO } from "date-fns";
|
||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { Check } from "lucide-react";
|
||||
import { EventCard } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
|
||||
interface CalendarDayViewProps {
|
||||
selectedDate: Date;
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 64;
|
||||
@@ -27,8 +37,16 @@ export function CalendarDayView({
|
||||
events,
|
||||
calendars,
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onCreateAtTime,
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
}: CalendarDayViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -46,22 +64,29 @@ export function CalendarDayView({
|
||||
const allDay: CalendarEvent[] = [];
|
||||
events.forEach((ev) => {
|
||||
try {
|
||||
const start = new Date(ev.start);
|
||||
const end = getEventEndDate(ev);
|
||||
const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
|
||||
const endDay = new Date(end); endDay.setHours(0, 0, 0, 0);
|
||||
const { startDay, endDay } = getEventDayBounds(ev);
|
||||
const selDay = new Date(selectedDate); selDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||
if (!spansThisDay) return;
|
||||
|
||||
if (ev.showWithoutTime) allDay.push(ev);
|
||||
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
|
||||
else timed.push(ev);
|
||||
} catch { /* skip invalid dates */ }
|
||||
});
|
||||
return { timedEvents: timed, allDayEvents: allDay };
|
||||
}, [events, selectedDate]);
|
||||
|
||||
const dayTasks = useMemo(() => {
|
||||
if (!tasks?.length) return [];
|
||||
return tasks.filter(task => {
|
||||
if (!task.due) return false;
|
||||
try {
|
||||
return isSameDay(parseISO(task.due), selectedDate);
|
||||
} catch { return false; }
|
||||
});
|
||||
}, [tasks, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const now = new Date();
|
||||
@@ -96,6 +121,7 @@ export function CalendarDayView({
|
||||
created: t("notifications.event_created"),
|
||||
error: t("notifications.event_error"),
|
||||
},
|
||||
isMobile,
|
||||
});
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
@@ -106,47 +132,97 @@ export function CalendarDayView({
|
||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||
};
|
||||
|
||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className={cn("text-lg font-semibold", today && "text-primary")}>
|
||||
{intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
|
||||
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||
{isMobile
|
||||
? intlFormatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
|
||||
: intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{allDayEvents.length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||
<div className="space-y-1">
|
||||
{allDayEvents.map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="chip"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||
<div
|
||||
className="px-4 py-2 border-b border-border"
|
||||
onContextMenu={onContextMenuEmpty ? (e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return;
|
||||
onContextMenuEmpty(e, selectedDate, undefined, true);
|
||||
} : undefined}
|
||||
>
|
||||
{allDayEvents.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||
<div className="space-y-1">
|
||||
{allDayEvents.map((ev) => {
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="chip"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{dayTasks.length > 0 && (
|
||||
<>
|
||||
<div className={cn("text-[10px] text-muted-foreground mb-1", allDayEvents.length > 0 && "mt-2")}>{t("tasks.label")}</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayTasks.map((task) => {
|
||||
const isCompleted = task.progress === "completed";
|
||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
style={{ borderLeft: `3px solid ${color}` }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleTaskComplete?.(task); }}
|
||||
className={cn(
|
||||
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
|
||||
isCompleted
|
||||
? "bg-success border-success text-success-foreground"
|
||||
: "border-muted-foreground/40 hover:border-primary"
|
||||
)}
|
||||
>
|
||||
{isCompleted && <Check className="h-2.5 w-2.5" />}
|
||||
</button>
|
||||
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||
{task.title || t("tasks.no_title")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||
<div className="w-16 flex-shrink-0">
|
||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="relative text-muted-foreground text-right pr-3"
|
||||
className="relative text-muted-foreground text-right pr-2"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{h > 0 && (
|
||||
<span className="absolute top-0 right-3 -translate-y-1/2 text-xs leading-none">
|
||||
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[10px]" : "text-xs")}>
|
||||
{formatHour(h)}
|
||||
</span>
|
||||
)}
|
||||
@@ -172,19 +248,20 @@ export function CalendarDayView({
|
||||
aria-label={formatHour(h)}
|
||||
onClick={() => handleSlotClick(selectedDate, h)}
|
||||
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
||||
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined}
|
||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
||||
const start = parseISO(ev.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const isResizing = resizeVisual?.eventId === ev.id;
|
||||
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
|
||||
@@ -197,16 +274,29 @@ export function CalendarDayView({
|
||||
>
|
||||
<EventCard
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="block"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||
</div>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
@@ -222,8 +312,8 @@ export function CalendarDayView({
|
||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500 -ml-1" />
|
||||
<div className="flex-1 h-px bg-red-500" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
|
||||
<div className="flex-1 h-px bg-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -264,6 +354,34 @@ export function CalendarDayView({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && (
|
||||
(() => {
|
||||
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||
const durationMin = Math.max(15, endMin - startMin);
|
||||
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||
const color = cal?.color || "hsl(var(--primary))";
|
||||
return (
|
||||
<div
|
||||
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
|
||||
style={{
|
||||
top: (startMin / 60) * HOUR_HEIGHT,
|
||||
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}10`,
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
|
||||
{pendingPreview.title}
|
||||
</div>
|
||||
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
|
||||
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard } from "./event-card";
|
||||
import { getEventEndDate } from "@/lib/calendar-utils";
|
||||
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface CalendarMonthViewProps {
|
||||
@@ -20,7 +21,14 @@ interface CalendarMonthViewProps {
|
||||
calendars: Calendar[];
|
||||
onSelectDate: (date: Date) => void;
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
}
|
||||
|
||||
export function CalendarMonthView({
|
||||
@@ -29,7 +37,14 @@ export function CalendarMonthView({
|
||||
calendars,
|
||||
onSelectDate,
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onCreateAtTime,
|
||||
firstDayOfWeek = 1,
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -53,12 +68,7 @@ export function CalendarMonthView({
|
||||
const map = new Map<string, CalendarEvent[]>();
|
||||
events.forEach((e) => {
|
||||
try {
|
||||
const start = new Date(e.start);
|
||||
const end = getEventEndDate(e);
|
||||
const startDay = new Date(start);
|
||||
startDay.setHours(0, 0, 0, 0);
|
||||
const endDay = new Date(end);
|
||||
endDay.setHours(0, 0, 0, 0);
|
||||
const { startDay, endDay } = getEventDayBounds(e);
|
||||
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
@@ -85,6 +95,14 @@ export function CalendarMonthView({
|
||||
return result;
|
||||
}, [days]);
|
||||
|
||||
const weekSegments = useMemo(() => {
|
||||
return weeks.map((week) => {
|
||||
const segments = buildWeekSegments(events, week);
|
||||
const rowCount = segments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||
return { week, segments, rowCount };
|
||||
});
|
||||
}, [events, weeks]);
|
||||
|
||||
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
||||
|
||||
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
||||
@@ -107,13 +125,14 @@ export function CalendarMonthView({
|
||||
try {
|
||||
const data = JSON.parse(json);
|
||||
const originalStart = parseISO(data.originalStart);
|
||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
||||
const isAllDay = event?.showWithoutTime;
|
||||
const newStart = new Date(day);
|
||||
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
|
||||
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||
} catch {
|
||||
@@ -125,22 +144,28 @@ export function CalendarMonthView({
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
|
||||
<div className="grid grid-cols-7 border-b border-border" role="row">
|
||||
{dayHeaders.map((d) => (
|
||||
<div key={d} role="columnheader" className="text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0">
|
||||
{t(`days.${d}`)}
|
||||
<div key={d} role="columnheader" className={cn(
|
||||
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
|
||||
isMobile && "py-1.5 text-[11px]"
|
||||
)}>
|
||||
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="grid grid-cols-7 flex-1 min-h-[100px] border-b border-border last:border-b-0" role="row">
|
||||
{weekSegments.map(({ week, segments, rowCount }, wi) => (
|
||||
<div key={wi} className={cn(
|
||||
"relative flex-1 border-b border-border last:border-b-0",
|
||||
isMobile ? "min-h-[52px]" : "min-h-[100px]"
|
||||
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
|
||||
<div className="grid grid-cols-7 h-full">
|
||||
{week.map((day) => {
|
||||
const inMonth = isSameMonth(day, selectedDate);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = eventsByDate.get(key) || [];
|
||||
const maxVisible = 3;
|
||||
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
|
||||
return (
|
||||
@@ -150,20 +175,24 @@ export function CalendarMonthView({
|
||||
aria-selected={selected}
|
||||
aria-label={fullDateLabel}
|
||||
onClick={() => onSelectDate(day)}
|
||||
onDoubleClick={() => onCreateAtTime?.(day)}
|
||||
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||
onDragOver={(e) => handleCellDragOver(e, key)}
|
||||
onDragLeave={handleCellDragLeave}
|
||||
onDrop={(e) => handleCellDrop(e, day)}
|
||||
className={cn(
|
||||
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors",
|
||||
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
|
||||
!inMonth && "bg-muted/30",
|
||||
"hover:bg-muted/50",
|
||||
selected && isMobile && "bg-primary/10",
|
||||
dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center w-6 h-6 text-xs rounded-full",
|
||||
"inline-flex items-center justify-center rounded-full",
|
||||
isMobile ? "w-7 h-7 text-xs" : "w-6 h-6 text-xs",
|
||||
today && !selected && "bg-primary text-primary-foreground font-bold",
|
||||
selected && "bg-primary text-primary-foreground font-bold",
|
||||
!inMonth && !selected && !today && "text-muted-foreground/50",
|
||||
@@ -173,29 +202,96 @@ export function CalendarMonthView({
|
||||
{format(day, "d")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayEvents.slice(0, maxVisible).map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="chip"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
draggable
|
||||
{isMobile ? (
|
||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||
{dayEvents.slice(0, 3).map((ev) => {
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||
return (
|
||||
<span
|
||||
key={ev.id}
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: evColor }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{dayEvents.length > 3 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||
)}
|
||||
{pendingPreview && isSameDay(pendingPreview.start, day) && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{dayEvents.length > maxVisible && (
|
||||
<div className="text-[10px] text-muted-foreground px-1">
|
||||
{t("events.more", { count: dayEvents.length - maxVisible })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isMobile && pendingPreview && (() => {
|
||||
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
|
||||
if (previewDayIdx === -1) return null;
|
||||
const previewRow = rowCount;
|
||||
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
return (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||
<div
|
||||
className="absolute px-0.5"
|
||||
style={{
|
||||
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||
top: previewRow * 22,
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
|
||||
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
||||
>
|
||||
{pendingPreview.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!isMobile && segments.length > 0 && (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||
{segments.map((segment) => {
|
||||
const calId = getPrimaryCalendarId(segment.event);
|
||||
return (
|
||||
<div
|
||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
||||
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
|
||||
top: segment.row * 22,
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
<EventCard
|
||||
event={segment.event}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="span"
|
||||
continuesBefore={segment.continuesBefore}
|
||||
continuesAfter={segment.continuesAfter}
|
||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user