Compare commits
699
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fcd37650d | ||
|
|
13ec05da83 | ||
|
|
e7acf56753 | ||
|
|
83e29b3ef1 | ||
|
|
67f61f18d0 | ||
|
|
f34537adcf | ||
|
|
931d1fa06a | ||
|
|
47b9ab4398 | ||
|
|
4653de6d30 | ||
|
|
671857722d | ||
|
|
62b0455388 | ||
|
|
372722a903 | ||
|
|
35ed6a2858 | ||
|
|
bd778adf12 | ||
|
|
1a19e96bcb | ||
|
|
6dc6ad0936 | ||
|
|
87336981d3 | ||
|
|
d8bebb531f | ||
|
|
57c06e38a4 | ||
|
|
f6fc34fab3 | ||
|
|
98dcd3b1e9 | ||
|
|
98f8487784 | ||
|
|
1f199fdc1d | ||
|
|
8ddb9c6dbd | ||
|
|
81c720d630 | ||
|
|
3bf36de0c8 | ||
|
|
b648c1c267 | ||
|
|
a4b330bace | ||
|
|
971afbecc1 | ||
|
|
2ac022df50 | ||
|
|
7b2047681e | ||
|
|
2aae8ed842 | ||
|
|
2a8778c905 | ||
|
|
2dc224e882 | ||
|
|
3d5c05ffb9 | ||
|
|
f46614a483 | ||
|
|
30d6c23908 | ||
|
|
bde63c1322 | ||
|
|
dfa5667548 | ||
|
|
64f031201e | ||
|
|
77a9060c00 | ||
|
|
7082566f51 | ||
|
|
cd02a9f806 | ||
|
|
59048e5ad3 | ||
|
|
9377684fb1 | ||
|
|
26e1f31945 | ||
|
|
611ae0624e | ||
|
|
68b0826587 | ||
|
|
eda3302298 | ||
|
|
295170a842 | ||
|
|
30e5059b94 | ||
|
|
6fd17c2ade | ||
|
|
61651b1ed1 | ||
|
|
daa40ec72d | ||
|
|
c2c07293b7 | ||
|
|
91b282d746 | ||
|
|
dda7adf565 | ||
|
|
bde8455df5 | ||
|
|
bde9f14832 | ||
|
|
3f99a2fc9e | ||
|
|
147660aef3 | ||
|
|
5d7ae230ce | ||
|
|
2a35019b21 | ||
|
|
4ba2d34555 | ||
|
|
cfe8ca96e1 | ||
|
|
e1a12b2c23 | ||
|
|
e44f2ac97e | ||
|
|
9b5870ca69 | ||
|
|
2e8bb9983a | ||
|
|
d0a1cee6fd | ||
|
|
19663610d7 | ||
|
|
36167eaa84 | ||
|
|
c71175e596 | ||
|
|
68d08dbae6 | ||
|
|
7cce5c0393 | ||
|
|
6b6ff72c38 | ||
|
|
57a5c692be | ||
|
|
84290a67be | ||
|
|
ada2b3a7a1 | ||
|
|
3338ceb5eb | ||
|
|
15b189357e | ||
|
|
ab79288be8 | ||
|
|
e6e1612435 | ||
|
|
f01f50922e | ||
|
|
48b18a853f | ||
|
|
665a392ce0 | ||
|
|
505e65f319 | ||
|
|
177b2aca57 | ||
|
|
3512f935d1 | ||
|
|
12908ab706 | ||
|
|
a10ee48ef3 | ||
|
|
31b4ea2ecd | ||
|
|
0271df4338 | ||
|
|
7e9aefcfa1 | ||
|
|
b966d285a9 | ||
|
|
16466c7296 | ||
|
|
2ff4b7847e | ||
|
|
46fc221f9e | ||
|
|
b15098a6eb | ||
|
|
0f15132ec0 | ||
|
|
3f3f3a36b1 | ||
|
|
5d77a5d7ef | ||
|
|
75876725df | ||
|
|
2416f1863b | ||
|
|
b6fdfe72ca | ||
|
|
568b7137ea | ||
|
|
3afa7ce012 | ||
|
|
0bb098438a | ||
|
|
759ab7fe8c | ||
|
|
fb40e74713 | ||
|
|
cab43b8d06 | ||
|
|
4d817ea932 | ||
|
|
b8f668d25a | ||
|
|
9254a7fa20 | ||
|
|
fe77e9f52b | ||
|
|
4ff15fffaa | ||
|
|
218a584fb3 | ||
|
|
a9af816012 | ||
|
|
90c1176f93 | ||
|
|
5c1f14fa8b | ||
|
|
a4155aa342 | ||
|
|
d047891ded | ||
|
|
bc5d2a57e8 | ||
|
|
f7e487171c | ||
|
|
e9746fcf78 | ||
|
|
d91db37b34 | ||
|
|
ae19ad888b | ||
|
|
f0e63de09b | ||
|
|
88670d4bcf | ||
|
|
1bc5a6a0ce | ||
|
|
be7bfd1f02 | ||
|
|
22e5e97da9 | ||
|
|
476c76c420 | ||
|
|
a3d551b640 | ||
|
|
83a9c5a809 | ||
|
|
fb4e8f3591 | ||
|
|
0189935e7b | ||
|
|
eace443fdf | ||
|
|
e94a1429d5 | ||
|
|
15982c2468 | ||
|
|
d15c58f8da | ||
|
|
6698ec8456 | ||
|
|
e738941950 | ||
|
|
1890cade08 | ||
|
|
fc7fec44a8 | ||
|
|
1652a0ec62 | ||
|
|
e659fe3d38 | ||
|
|
348e032dce | ||
|
|
a6c15b8ad6 | ||
|
|
5a2bc6b671 | ||
|
|
59bc7fd64c | ||
|
|
aa7a814b86 | ||
|
|
c5c6509867 | ||
|
|
1cdbf75270 | ||
|
|
ea7892b497 | ||
|
|
d52dfebad4 | ||
|
|
7bb58f4f9f | ||
|
|
f1e1ed1df7 | ||
|
|
d9d9f91a86 | ||
|
|
108406a885 | ||
|
|
013ef7d557 | ||
|
|
56d11b5759 | ||
|
|
0c1e238223 | ||
|
|
ca0ba818b7 | ||
|
|
ce97a54aaa | ||
|
|
b605b6d49d | ||
|
|
cea3ec0fe6 | ||
|
|
f56ca594dc | ||
|
|
66ff523553 | ||
|
|
7969fd09eb | ||
|
|
3108b2f336 | ||
|
|
71565e9328 | ||
|
|
edd11ac27b | ||
|
|
f81b02ead9 | ||
|
|
25e542867b | ||
|
|
0a1114f710 | ||
|
|
3bfa73f375 | ||
|
|
fc50e5b569 | ||
|
|
a34314cef5 | ||
|
|
0563e88eb8 | ||
|
|
7a483b3dd4 | ||
|
|
351f2d4e26 | ||
|
|
f3b6819463 | ||
|
|
3ea22161d9 | ||
|
|
246df49c03 | ||
|
|
9c04950a94 | ||
|
|
934967b9df | ||
|
|
755201c92a | ||
|
|
9b69d89dfd | ||
|
|
17b69e68f3 | ||
|
|
83cd675ccf | ||
|
|
ddcab88e56 | ||
|
|
f188e29152 | ||
|
|
b48b6e0871 | ||
|
|
6248bb9825 | ||
|
|
15ad783848 | ||
|
|
fc116a8b2f | ||
|
|
a16478ffad | ||
|
|
144d6503cc | ||
|
|
9c6292b4b7 | ||
|
|
457063a48b | ||
|
|
21ea5009f4 | ||
|
|
74f9335e36 | ||
|
|
010f082c73 | ||
|
|
00dc509e60 | ||
|
|
af2b00dd35 | ||
|
|
e442c55931 | ||
|
|
2245ad2024 | ||
|
|
7511d8ea78 | ||
|
|
7bf62e4bdc | ||
|
|
959d4bd6ce | ||
|
|
b7c8cd999e | ||
|
|
813185e58d | ||
|
|
3f22a3323a | ||
|
|
0e4efb5a2a | ||
|
|
b354319b82 | ||
|
|
5a8c69dac2 | ||
|
|
24056e4698 | ||
|
|
5818e60401 | ||
|
|
7beaf991e8 | ||
|
|
5105e000f5 | ||
|
|
66bc10fa0f | ||
|
|
07c473e057 | ||
|
|
0e47c3b039 | ||
|
|
e1a973663f | ||
|
|
de55fb6b73 | ||
|
|
a909593dda | ||
|
|
4ad9267a2d | ||
|
|
23a017d4b7 | ||
|
|
c7250dc921 | ||
|
|
80f76abc38 | ||
|
|
adb8686293 | ||
|
|
d531ad1930 | ||
|
|
953355d2a5 | ||
|
|
8155f98a28 | ||
|
|
fda898fc96 | ||
|
|
3dceecb4c5 | ||
|
|
53461d1142 | ||
|
|
4d9d992f3f | ||
|
|
162e420a1f | ||
|
|
e8f01871c2 | ||
|
|
6dfcb07b9a | ||
|
|
0f3459c2e5 | ||
|
|
d5017a211f | ||
|
|
f51ec50443 | ||
|
|
f2703bcc27 | ||
|
|
cda4dcbf01 | ||
|
|
f15edd336b | ||
|
|
a3f9055541 | ||
|
|
a779e101e6 | ||
|
|
c38bcc4a95 | ||
|
|
5716d91115 | ||
|
|
2f791318df | ||
|
|
60b9ae66ea | ||
|
|
abd493fb4c | ||
|
|
b4739c111f | ||
|
|
88b07a1713 | ||
|
|
18e9cf6ee6 | ||
|
|
2cb5c739b4 | ||
|
|
0a30b2fb3a | ||
|
|
0b62afb0f8 | ||
|
|
f749ee1f2a | ||
|
|
4a4950c3e5 | ||
|
|
739b72d251 | ||
|
|
682e47c970 | ||
|
|
a6d8671306 | ||
|
|
6f278845f3 | ||
|
|
334fdbfb86 | ||
|
|
578339c400 | ||
|
|
8d8bc7cb13 | ||
|
|
4dc76bbb47 | ||
|
|
04444003b2 | ||
|
|
7da3d4ae80 | ||
|
|
cb5d754113 | ||
|
|
511f9e5195 | ||
|
|
996fa7eea6 | ||
|
|
01e5cd69cf | ||
|
|
20d02214df | ||
|
|
432ba0516b | ||
|
|
37152504b4 | ||
|
|
9072bf8470 | ||
|
|
b1f6758f98 | ||
|
|
a679d82cc3 | ||
|
|
a08a9e9ed3 | ||
|
|
622adc34de | ||
|
|
4a3394cf8c | ||
|
|
a8598db44d | ||
|
|
d3addf54b4 | ||
|
|
e1e83c4a83 | ||
|
|
cdb31634a6 | ||
|
|
26c3d07d56 | ||
|
|
c3acb537d0 | ||
|
|
060c5d00d1 | ||
|
|
e05fbb2fe9 | ||
|
|
b8809c2e69 | ||
|
|
bc11450f3f | ||
|
|
034f7a4b9b | ||
|
|
2e42693228 | ||
|
|
fdad60cf03 | ||
|
|
dc72122ed8 | ||
|
|
7c221c4a4a | ||
|
|
c1acf58c5f | ||
|
|
42798c2b7c | ||
|
|
75d17d4e37 | ||
|
|
38a396d150 | ||
|
|
c6bd5f645a | ||
|
|
fa31933922 | ||
|
|
d3f77ef9cf | ||
|
|
5ccba83129 | ||
|
|
752e71198c | ||
|
|
2cb74c0186 | ||
|
|
9ab7339320 | ||
|
|
782974ecdb | ||
|
|
c47137fb49 | ||
|
|
38313639ed | ||
|
|
e933800792 | ||
|
|
60a1cc670c | ||
|
|
c0515001f1 | ||
|
|
e10fced28a | ||
|
|
3d36492518 | ||
|
|
94af4725b6 | ||
|
|
22418c17cf | ||
|
|
8904d724bb | ||
|
|
c7d551f185 | ||
|
|
6470fa86f0 | ||
|
|
8647d709ff | ||
|
|
b73d1b55d1 | ||
|
|
7db6fd7e24 | ||
|
|
e066698938 | ||
|
|
c3a97de62f | ||
|
|
29283282d5 | ||
|
|
db2c642d74 | ||
|
|
9110bc388f | ||
|
|
a4dc0b7b4e | ||
|
|
d384c3b553 | ||
|
|
d7a64fd9d6 | ||
|
|
06ddda688d | ||
|
|
6b74615969 | ||
|
|
0d73cb5dfb | ||
|
|
b1b09d54c8 | ||
|
|
9930d19ba4 | ||
|
|
902774eae1 | ||
|
|
1429a6fe1e | ||
|
|
d1da83a5d6 | ||
|
|
babacca482 | ||
|
|
e6aa79ed94 | ||
|
|
4d6b4b5b8e | ||
|
|
d259168bd7 | ||
|
|
97d87c44d0 | ||
|
|
396f5f7d60 | ||
|
|
9aef8bc393 | ||
|
|
e05ab48a45 | ||
|
|
14c2807f0a | ||
|
|
a099ab442a | ||
|
|
e9ac2de6cb | ||
|
|
c4a7f575c5 | ||
|
|
51c3a69be7 | ||
|
|
717b1d8397 | ||
|
|
f291480565 | ||
|
|
95b5a81924 | ||
|
|
9da27df249 | ||
|
|
b716f95a73 | ||
|
|
6c49427c7c | ||
|
|
2a41e73bf9 | ||
|
|
16daf6ea03 | ||
|
|
65cb6be8a9 | ||
|
|
2704d5dc23 | ||
|
|
2f5b133000 | ||
|
|
1f21a5213f | ||
|
|
d4c066622b | ||
|
|
e141abc849 | ||
|
|
1f4afe082b | ||
|
|
e0747c12ee | ||
|
|
f90cd6abc4 | ||
|
|
63e087f3ef | ||
|
|
f8b8e0b108 | ||
|
|
512adab7e3 | ||
|
|
4cdc15fc3c | ||
|
|
5e67671f57 | ||
|
|
155d99a069 | ||
|
|
d863b1fd4b | ||
|
|
70aaf0aac1 | ||
|
|
de56229ef2 | ||
|
|
1ff23790ae | ||
|
|
198407ebf5 | ||
|
|
de68d68fb7 | ||
|
|
f285a2bd64 | ||
|
|
84b6d0fd8b | ||
|
|
f972068143 | ||
|
|
7882b254a0 | ||
|
|
ae5d397512 | ||
|
|
7a022596c3 | ||
|
|
8c575e8ed8 | ||
|
|
85fbab9eb4 | ||
|
|
1119d8ed73 | ||
|
|
32f0a67dbc | ||
|
|
baf094d026 | ||
|
|
f1b9f4ba50 | ||
|
|
cc20d29636 | ||
|
|
5f713a033b | ||
|
|
079ec57204 | ||
|
|
cd247e9c47 | ||
|
|
8af6694152 | ||
|
|
751f3c1685 | ||
|
|
5c2f206c74 | ||
|
|
acc61db6f2 | ||
|
|
d671c606a1 | ||
|
|
fa3c57467b | ||
|
|
befee332d2 | ||
|
|
a29c33b50a | ||
|
|
3dd596ba50 | ||
|
|
e50fe0d4db | ||
|
|
3d3ad8f0ef | ||
|
|
d0ed4b4dfe | ||
|
|
5306f7c548 | ||
|
|
ddb596affc | ||
|
|
bfc8ba851a | ||
|
|
3f9e60843d | ||
|
|
2fac6ebfb8 | ||
|
|
dda9fd1433 | ||
|
|
3516d3c727 | ||
|
|
0b7203df0f | ||
|
|
344795a8d9 | ||
|
|
638fc7db4e | ||
|
|
ab3e0e717a | ||
|
|
c9eae3b3a1 | ||
|
|
6f615f4c32 | ||
|
|
52eacf87b9 | ||
|
|
c4f1cc23d7 | ||
|
|
4f1390fdeb | ||
|
|
c0ca6d3102 | ||
|
|
0872d3dc8d | ||
|
|
1f889b0965 | ||
|
|
0b9fe5451f | ||
|
|
fd700f412e | ||
|
|
f7d4f9d53c | ||
|
|
84aced7b4e | ||
|
|
94b1f5aa48 | ||
|
|
c51c3655d5 | ||
|
|
404a1e847c | ||
|
|
dcea4fdd5e | ||
|
|
701f96adb3 | ||
|
|
1ebfb286ad | ||
|
|
b5f15dfdb7 | ||
|
|
aee4bd78db | ||
|
|
798a33495e | ||
|
|
4c6c1aab60 | ||
|
|
848ed9774d | ||
|
|
b32e102ff9 | ||
|
|
e8feb11983 | ||
|
|
3e12fca517 | ||
|
|
8df483d8c7 | ||
|
|
1e63e2469a | ||
|
|
e1c28e767a | ||
|
|
fe5645c818 | ||
|
|
38570b1723 | ||
|
|
a4f476945d | ||
|
|
20fd9ff4de | ||
|
|
569dde9985 | ||
|
|
08f344403b | ||
|
|
be58cee989 | ||
|
|
7c11e2b3c9 | ||
|
|
f2913a7c7d | ||
|
|
a27a5be3dd | ||
|
|
964136b540 | ||
|
|
569f688fbf | ||
|
|
1d050f8469 | ||
|
|
2e4c0f9eea | ||
|
|
fd0b866339 | ||
|
|
1518ba04dd | ||
|
|
c5672c64fb | ||
|
|
5997579f54 | ||
|
|
f77d2e9103 | ||
|
|
6350e9dabc | ||
|
|
7723c134ff | ||
|
|
d11ed904a9 | ||
|
|
9db7b6b55f | ||
|
|
1460706e60 | ||
|
|
27d624758a | ||
|
|
5288821605 | ||
|
|
58e3ecc97a | ||
|
|
38c0694a08 | ||
|
|
71d25bb62f | ||
|
|
ef825b801a | ||
|
|
50cbd66bfd | ||
|
|
d564c874a3 | ||
|
|
568abb0e33 | ||
|
|
44781f002c | ||
|
|
941fa15251 | ||
|
|
60c7bd713e | ||
|
|
6f193e0c24 | ||
|
|
6bb85d746c | ||
|
|
bbd43b5948 | ||
|
|
180331805f | ||
|
|
9e96b1c24e | ||
|
|
40b4b26074 | ||
|
|
9863b9d88e | ||
|
|
75602b6a00 | ||
|
|
22da11514b | ||
|
|
9953557af0 | ||
|
|
8f7066d194 | ||
|
|
75c02f443f | ||
|
|
31100b8f87 | ||
|
|
152ec99262 | ||
|
|
ce401c0f59 | ||
|
|
3035fb046f | ||
|
|
4659b81538 | ||
|
|
c78dbee60b | ||
|
|
4b4c801148 | ||
|
|
2e4d3d4bc6 | ||
|
|
26ccf9e3b4 | ||
|
|
bc322a1e69 | ||
|
|
6ee3849463 | ||
|
|
abb249e5df | ||
|
|
0352312f25 | ||
|
|
ae66f8d89d | ||
|
|
55be19ede7 | ||
|
|
b508551d02 | ||
|
|
7ee329e046 | ||
|
|
1512b9afc0 | ||
|
|
ad48f4394a | ||
|
|
8d79145dba | ||
|
|
b821f8cc27 | ||
|
|
bebb394f54 | ||
|
|
2ba0003e16 | ||
|
|
4c1d0931a1 | ||
|
|
5a70cf95e0 | ||
|
|
66c5f0f52c | ||
|
|
8353b28b33 | ||
|
|
229992853b | ||
|
|
f0d87d594a | ||
|
|
196e51e91b | ||
|
|
0879030dc8 | ||
|
|
05e2837f6b | ||
|
|
241544cd08 | ||
|
|
7341e47a1b | ||
|
|
f238ca5898 | ||
|
|
00b40fc48a | ||
|
|
856496715b | ||
|
|
eb7eeae1ac | ||
|
|
6b1a99a70b | ||
|
|
1da04c254b | ||
|
|
8ae5ecba41 | ||
|
|
4ff05bd4ec | ||
|
|
31e96d6a46 | ||
|
|
82be047708 | ||
|
|
7d1fb73290 | ||
|
|
2818a16f06 | ||
|
|
e93dd44111 | ||
|
|
e86183b44a | ||
|
|
ad80aa23ca | ||
|
|
3a8daf8bff | ||
|
|
f8e7cce85a | ||
|
|
62ebe443f9 | ||
|
|
3c0faba837 | ||
|
|
956acb69ce | ||
|
|
e2abc8dee9 | ||
|
|
42ec34be21 | ||
|
|
534894c38d | ||
|
|
537707d9ed | ||
|
|
afe1e5a67c | ||
|
|
d13934c2a6 | ||
|
|
acc90eb455 | ||
|
|
5aa6d7a2f0 | ||
|
|
c46de636e0 | ||
|
|
8de8babba5 | ||
|
|
63f2169ae7 | ||
|
|
e843ef0ebb | ||
|
|
4b463f9691 | ||
|
|
58e4a3d117 | ||
|
|
e3f6ae874d | ||
|
|
52f5a5b42c | ||
|
|
e7bded82fb | ||
|
|
3ac14ecf38 | ||
|
|
0dca019fe5 | ||
|
|
ca0d6805cf | ||
|
|
8bcb487442 | ||
|
|
0245ec67e1 | ||
|
|
8810a63262 | ||
|
|
d3778e6521 | ||
|
|
1fc6185002 | ||
|
|
4269d0589c | ||
|
|
2b1b06abd6 | ||
|
|
ac4a89120d | ||
|
|
704a259432 | ||
|
|
1c02970ae1 | ||
|
|
66b2036e37 | ||
|
|
bd2ffab3bc | ||
|
|
7142627cec | ||
|
|
1e7d2d880c | ||
|
|
63efd724d2 | ||
|
|
ba4781910d | ||
|
|
08c85a42e1 | ||
|
|
fc5f6f43d6 | ||
|
|
9b22ef810e | ||
|
|
dbc0eea148 | ||
|
|
e80412b6fd | ||
|
|
2c825af689 | ||
|
|
4cfee4f672 | ||
|
|
7076f1ded8 | ||
|
|
9fbcdf7a5f | ||
|
|
7f35d792b2 | ||
|
|
c9cda3e203 | ||
|
|
3540bf42d9 | ||
|
|
e6782e61a8 | ||
|
|
33e655bcce | ||
|
|
15a67a14b3 | ||
|
|
b48eef2189 | ||
|
|
280f5bc675 | ||
|
|
1756f5ac1c | ||
|
|
c9435f7580 | ||
|
|
426d344aa8 | ||
|
|
ed90e096b5 | ||
|
|
eb6e5f589e | ||
|
|
eca837962c | ||
|
|
b75bbaa517 | ||
|
|
9763ffa2a3 | ||
|
|
d854b903e0 | ||
|
|
5008857880 | ||
|
|
628966d3b5 | ||
|
|
d45c8ef511 | ||
|
|
ba90ec1f7a | ||
|
|
5023d31202 | ||
|
|
1c44f59ba1 | ||
|
|
433a63bf1a | ||
|
|
de847b9e9f | ||
|
|
d530d9614b | ||
|
|
9a92271f6f | ||
|
|
97ddf935a8 | ||
|
|
973ce1e5bd | ||
|
|
7003020855 | ||
|
|
5cdc5997af | ||
|
|
c3b4707f85 | ||
|
|
2b3094a4ef | ||
|
|
ecd0467ffa | ||
|
|
43ac0725ce | ||
|
|
98879802ae | ||
|
|
cf9292262d | ||
|
|
3d2ed71f3a | ||
|
|
dcd2f4b079 | ||
|
|
b2d24670ff | ||
|
|
fa261fecfd | ||
|
|
40f36baf94 | ||
|
|
400703154e | ||
|
|
3ceada7b8a | ||
|
|
eb0643d887 | ||
|
|
1fc670138b | ||
|
|
313a1fcce9 | ||
|
|
7efd8d59bf | ||
|
|
c2eb2c081b | ||
|
|
b299a0b602 | ||
|
|
f275fbe2e4 | ||
|
|
f134766fd1 | ||
|
|
6ebf720688 | ||
|
|
b1eb2b3c9b | ||
|
|
48aa607b56 | ||
|
|
088810bd20 | ||
|
|
e16f572252 | ||
|
|
9f312aa556 | ||
|
|
c5ac68e137 | ||
|
|
ed6b5d5f33 | ||
|
|
7a72903632 | ||
|
|
92127e2f00 | ||
|
|
be5ff96e4d | ||
|
|
1f47b7a6a9 | ||
|
|
551984ac44 | ||
|
|
8c5aec9ca4 | ||
|
|
375220298d | ||
|
|
452976ed95 | ||
|
|
243a2adfbf | ||
|
|
1ba4a13353 | ||
|
|
5de12dfb79 | ||
|
|
689d646c57 | ||
|
|
49cd7f8130 | ||
|
|
4545e212f4 | ||
|
|
b1f4f6eae0 | ||
|
|
9a431a873b | ||
|
|
bb7e1c4538 | ||
|
|
356abcfc2d | ||
|
|
3099b4801e | ||
|
|
fc641e94ac | ||
|
|
0e758409ee | ||
|
|
8c93941d8d | ||
|
|
4221c9a50f | ||
|
|
3a559479bd | ||
|
|
482493a10d | ||
|
|
0e1036eb49 | ||
|
|
349406723c | ||
|
|
997bedc91b | ||
|
|
307e6d5d34 | ||
|
|
ca1108f455 | ||
|
|
0ff88f36ed | ||
|
|
285b4e349c | ||
|
|
2b4ebb1fbb | ||
|
|
a829c2818f | ||
|
|
c54cf73c3a | ||
|
|
c45ef86924 | ||
|
|
f39366b470 | ||
|
|
b725000f4d |
@@ -6,6 +6,15 @@ node_modules
|
|||||||
!.env.example
|
!.env.example
|
||||||
!.env.dev.example
|
!.env.dev.example
|
||||||
scripts/
|
scripts/
|
||||||
|
# ...except the first-party plugin builder, which the image build runs
|
||||||
|
# (see Dockerfile). Without this the whole scripts/ dir is absent from the
|
||||||
|
# build context and the RUN step fails with "Cannot find module".
|
||||||
|
!scripts/build-plugins.mjs
|
||||||
TODO.md
|
TODO.md
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
# Sibling projects / test harness - not part of the webmail image
|
||||||
|
examples/
|
||||||
|
integration/
|
||||||
|
e2e/
|
||||||
|
**/node_modules
|
||||||
|
|||||||
+25
-4
@@ -15,9 +15,15 @@
|
|||||||
DEV_MOCK_JMAP=true
|
DEV_MOCK_JMAP=true
|
||||||
|
|
||||||
# Point the app at its own mock endpoint.
|
# Point the app at its own mock endpoint.
|
||||||
# IMPORTANT: This must match the origin the app runs on (default: port 3000).
|
# IMPORTANT: must be an ABSOLUTE URL matching the origin the app runs on
|
||||||
# Using a different port (e.g. 3001) will cause CORS errors.
|
# (default: port 3000) - NOT a relative path. A relative path here makes
|
||||||
JMAP_SERVER_URL=/api/dev-jmap
|
# /api/auth/stalwart-context 400 on every request (resolveTrustedJmapUrl
|
||||||
|
# rejects it), which silently breaks the real server-side session-cookie
|
||||||
|
# flow that S/MIME enrollment, offline sync, and the AI server/retrieval
|
||||||
|
# routes all depend on. The client-side mock fetch works either way, which
|
||||||
|
# is why this is easy to miss - it only bites features needing a real
|
||||||
|
# server-side session identity.
|
||||||
|
JMAP_SERVER_URL=http://localhost:3000/api/dev-jmap
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# App
|
# App
|
||||||
@@ -29,7 +35,7 @@ APP_NAME=Bulwark Webmail (Dev)
|
|||||||
# Session & Settings Sync (optional for dev)
|
# Session & Settings Sync (optional for dev)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
SESSION_SECRET=dev-secret-not-for-production
|
SESSION_SECRET=dev-secret-not-for-production-32chars
|
||||||
SETTINGS_SYNC_ENABLED=true
|
SETTINGS_SYNC_ENABLED=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -39,6 +45,16 @@ SETTINGS_SYNC_ENABLED=true
|
|||||||
LOG_FORMAT=text
|
LOG_FORMAT=text
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Plugin Development
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Load plugins from a directory on disk instead of installing them as ZIPs.
|
||||||
|
# Each immediate subfolder is one plugin and needs a manifest.json. When the
|
||||||
|
# manifest's entrypoint exists under src/, it's bundled on demand with esbuild,
|
||||||
|
# so you can edit sources and just refresh the browser.
|
||||||
|
# PLUGIN_DEV_DIR=../my-plugins
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Login Page Customization (optional)
|
# Login Page Customization (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -47,3 +63,8 @@ LOG_LEVEL=debug
|
|||||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
# LOGIN_WEBSITE_URL=https://example.com
|
# LOGIN_WEBSITE_URL=https://example.com
|
||||||
|
|
||||||
|
# Per-domain branding overrides. Each entry must have "host" (exact or
|
||||||
|
# "*.subdomain" wildcard) plus any subset of branding fields to override.
|
||||||
|
# Unset fields fall through to the global values above.
|
||||||
|
# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}]
|
||||||
|
|||||||
+218
-4
@@ -19,6 +19,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||||
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
|
||||||
|
# Offer several JMAP servers on the login form. JSON array; each entry needs
|
||||||
|
# id, label, and url. "domains" and a per-server "oauth" block are optional.
|
||||||
|
# Prefer configuring this from the admin dashboard - the env form exists for
|
||||||
|
# stateless deployments.
|
||||||
|
# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}]
|
||||||
|
|
||||||
|
# Pick the server automatically from the domain of the address the user types,
|
||||||
|
# matching against each entry's "domains" list. Default: false.
|
||||||
|
# JMAP_SERVER_AUTO_PICK_BY_DOMAIN=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stalwart Mail Server Integration
|
# Stalwart Mail Server Integration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -49,6 +59,29 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# OpenID Connect issuer URL for discovery
|
# OpenID Connect issuer URL for discovery
|
||||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||||
|
|
||||||
|
# Overrides only the user-facing authorize endpoint (e.g. a per-brand login
|
||||||
|
# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL.
|
||||||
|
# Leave unset to use the authorization_endpoint from discovery.
|
||||||
|
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
|
||||||
|
|
||||||
|
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
||||||
|
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
||||||
|
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||||
|
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
||||||
|
|
||||||
|
# Replace the scopes requested at authorization. Space-separated. Leave unset
|
||||||
|
# to use the defaults the client already asks for.
|
||||||
|
# OAUTH_SCOPES=openid email profile offline_access
|
||||||
|
|
||||||
|
# Append scopes instead of replacing them. Use this when your IdP needs one
|
||||||
|
# extra scope and you don't want to restate the defaults.
|
||||||
|
# OAUTH_EXTRA_SCOPES=groups
|
||||||
|
|
||||||
|
# Send the user straight to the identity provider, skipping the login form.
|
||||||
|
# Intended for embedded deployments where the parent app already authenticated
|
||||||
|
# them. Default: false.
|
||||||
|
# AUTO_SSO_ENABLED=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Session & Security
|
# Session & Security
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -105,12 +138,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Anonymous Telemetry
|
# Anonymous Telemetry
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
|
# Anonymous instance telemetry is OPT-IN and disabled by default. Enabling it
|
||||||
# version, platform, bucketed account counts, and feature toggles only. See
|
# helps us understand how Bulwark is used so we can make the product better.
|
||||||
|
# Heartbeats contain no PII: version, platform, bucketed account counts, and
|
||||||
|
# feature toggles only - never email addresses, hostnames, or IPs. See
|
||||||
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
||||||
#
|
#
|
||||||
# Disable telemetry entirely (overrides the admin UI):
|
# Enable telemetry (also toggleable in the admin UI):
|
||||||
# BULWARK_TELEMETRY=off
|
# BULWARK_TELEMETRY=on
|
||||||
|
#
|
||||||
|
# Setting this (on or off) locks the choice and disables the admin UI toggle.
|
||||||
|
|
||||||
# Directory for telemetry state: instance id, consent, login HMACs
|
# Directory for telemetry state: instance id, consent, login HMACs
|
||||||
# (default: ./data/telemetry). For Docker, the default resolves to
|
# (default: ./data/telemetry). For Docker, the default resolves to
|
||||||
@@ -118,6 +155,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# so the instance id and consent choice survive upgrades.
|
# so the instance id and consent choice survive upgrades.
|
||||||
# TELEMETRY_DATA_DIR=./data/telemetry
|
# TELEMETRY_DATA_DIR=./data/telemetry
|
||||||
|
|
||||||
|
# Legacy kill switch, honoured only when BULWARK_TELEMETRY is unset.
|
||||||
|
# BULWARK_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Let heartbeats reach a private/loopback address. Off by default as an SSRF
|
||||||
|
# guard; only useful when running a collector locally during development.
|
||||||
|
# BULWARK_TELEMETRY_ALLOW_PRIVATE=1
|
||||||
|
|
||||||
|
# Report a fixed Stalwart version instead of probing the JMAP server's Server
|
||||||
|
# header. Useful when a proxy strips that header.
|
||||||
|
# STALWART_VERSION=0.16.0
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Server Listen Address
|
# Server Listen Address
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -183,6 +231,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Should match your app's main background color. Default: #ffffff
|
# Should match your app's main background color. Default: #ffffff
|
||||||
# PWA_BACKGROUND_COLOR=#ffffff
|
# PWA_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
# Screenshots shown in the browser's install prompt. Absolute URLs or paths
|
||||||
|
# relative to public/. Both are optional; per-domain overrides are available
|
||||||
|
# through DOMAIN_BRANDING.
|
||||||
|
# PWA_SCREENSHOT_MOBILE_URL=/branding/screenshot-mobile.png
|
||||||
|
# PWA_SCREENSHOT_DESKTOP_URL=/branding/screenshot-desktop.png
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Logos
|
# Logos
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -220,6 +274,46 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
|||||||
# URL for the company website link on the login page.
|
# URL for the company website link on the login page.
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||||
|
|
||||||
|
# Cap the login logo's rendered size. Any CSS length ("120px", "8rem").
|
||||||
|
# Unset means the logo renders at its natural size.
|
||||||
|
# LOGIN_LOGO_MAX_HEIGHT=96px
|
||||||
|
# LOGIN_LOGO_MAX_WIDTH=320px
|
||||||
|
|
||||||
|
# Hide parts of the login page. All default to true.
|
||||||
|
# Turn the heading and subtitle off when the logo already reads as the brand.
|
||||||
|
# LOGIN_SHOW_HEADING=false
|
||||||
|
# LOGIN_SHOW_SUBTITLE=false
|
||||||
|
#
|
||||||
|
# Hide the optional TOTP field. A server that requires TOTP (totp_required)
|
||||||
|
# still shows it regardless of this setting.
|
||||||
|
# LOGIN_SHOW_TOTP=false
|
||||||
|
#
|
||||||
|
# Hide the version number, so it isn't disclosed to unauthenticated visitors.
|
||||||
|
# LOGIN_SHOW_VERSION=false
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-domain branding overrides (optional)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# When you serve the webmail on multiple hostnames, each hostname can override
|
||||||
|
# a subset of branding fields. Unset fields fall back to the global values
|
||||||
|
# above. Match is on the request's Host (or X-Forwarded-Host) header.
|
||||||
|
#
|
||||||
|
# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com"
|
||||||
|
# matches mail.example.com and any deeper subdomain, but NOT example.com).
|
||||||
|
# Exact matches always win over wildcards; the longest wildcard suffix wins
|
||||||
|
# among multiple wildcard matches.
|
||||||
|
#
|
||||||
|
# Overridable keys: appName, appShortName, appDescription, faviconUrl,
|
||||||
|
# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl,
|
||||||
|
# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName,
|
||||||
|
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl.
|
||||||
|
#
|
||||||
|
# Prefer setting this from the admin dashboard (PATCH /api/admin/config).
|
||||||
|
# The env-var form is provided for stateless deployments.
|
||||||
|
#
|
||||||
|
# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}]
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Extension Directory / Marketplace
|
# Extension Directory / Marketplace
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -229,6 +323,126 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
|||||||
# your own directory (e.g. http://localhost:3001 for local development).
|
# your own directory (e.g. http://localhost:3001 for local development).
|
||||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Admin Dashboard Access
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Bootstrap password for the admin dashboard. Read only when admin.json does
|
||||||
|
# not already exist; the app hashes it, writes admin.json, and logs a warning
|
||||||
|
# telling you to remove this variable. Without it (and without the setup
|
||||||
|
# wizard) the admin dashboard stays disabled.
|
||||||
|
# Accepts a plaintext password or an existing hash.
|
||||||
|
# ADMIN_PASSWORD=change-me
|
||||||
|
|
||||||
|
# Admin session lifetime in seconds. Default: 3600 (1 hour).
|
||||||
|
# ADMIN_SESSION_TTL=3600
|
||||||
|
|
||||||
|
# How many trusted reverse proxies sit in front of the app. The client IP is
|
||||||
|
# taken that many entries from the right of X-Forwarded-For, so an attacker
|
||||||
|
# can't spoof it by prepending values. Default: 1.
|
||||||
|
# TRUSTED_PROXY_DEPTH=2
|
||||||
|
|
||||||
|
# Allow search engines to index the app (robots.txt / noindex). Default: false.
|
||||||
|
# SEARCH_ENGINE_INDEXING=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Cookies, Embedding & Reverse Proxies
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# SameSite attribute for session cookies: lax (default), strict, or none.
|
||||||
|
# Embedding the app cross-origin in an iframe requires "none".
|
||||||
|
# COOKIE_SAME_SITE=none
|
||||||
|
|
||||||
|
# Force the Secure flag on cookies. Defaults to on when NODE_ENV=production or
|
||||||
|
# COOKIE_SAME_SITE=none. Set to false only for local HTTP development.
|
||||||
|
# COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Who may frame the app, as a CSP frame-ancestors value. Defaults to 'none',
|
||||||
|
# which blocks all framing. Space-separate multiple origins.
|
||||||
|
# ALLOWED_FRAME_ANCESTORS=https://portal.example.com
|
||||||
|
|
||||||
|
# Origin of the parent page when embedded, used for postMessage handshakes.
|
||||||
|
# NEXT_PUBLIC_PARENT_ORIGIN=https://portal.example.com
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Update Check
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# The app periodically checks for new releases and shows a notice. Set to
|
||||||
|
# "off" (or false/0/no) to disable the check entirely.
|
||||||
|
# BULWARK_UPDATE_CHECK=off
|
||||||
|
|
||||||
|
# Override the endpoint it checks. Takes priority over the on-disk state file.
|
||||||
|
# An explicit empty value also disables the check.
|
||||||
|
# BULWARK_UPDATE_CHECK_URL=https://updates.example.com/bulwark.json
|
||||||
|
|
||||||
|
# Where the check stores its state. Default: ./data/version-check
|
||||||
|
# VERSION_CHECK_DATA_DIR=./data/version-check
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Translation Proxy (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# /api/translate defaults to the public MyMemory API, which needs no setup.
|
||||||
|
# Point it at a LibreTranslate instance instead to keep message text on
|
||||||
|
# infrastructure you control. LibreTranslate also auto-detects the source
|
||||||
|
# language natively.
|
||||||
|
# LIBRETRANSLATE_URL=https://libretranslate.example.com
|
||||||
|
# LIBRETRANSLATE_API_KEY=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Web Push
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Push notifications go through a hosted relay so self-hosters don't need
|
||||||
|
# their own VAPID keys and Firebase project. Point this at your own relay to
|
||||||
|
# avoid the default. Build-time variable.
|
||||||
|
# Default: https://notifications.relay.bulwarkmail.org
|
||||||
|
# NEXT_PUBLIC_PUSH_RELAY_URL=https://push.example.com
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Demo Mode
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Serve fixture data instead of talking to a mail server. Default: false.
|
||||||
|
# DEMO_MODE=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stalwart Impersonation (advanced)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Lets a trusted platform mint a JWT that logs a user in without their
|
||||||
|
# password, using a Stalwart master account. Intended for embedded
|
||||||
|
# deployments where an outer platform already authenticated the user.
|
||||||
|
#
|
||||||
|
# SECURITY: this grants sign-in as any mailbox on the server. The endpoint
|
||||||
|
# returns 404 unless all three required variables below are set, so leaving
|
||||||
|
# them unset keeps the feature fully off. Treat the secret and the master
|
||||||
|
# password as you would a root credential.
|
||||||
|
#
|
||||||
|
# BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters
|
||||||
|
# BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com
|
||||||
|
# BULWARK_STALWART_MASTER_PASSWORD= # required
|
||||||
|
# BULWARK_JWT_AUTH_ISSUER= # optional, default "platform-api/webmail"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Internationalization
|
||||||
|
# =============================================================================
|
||||||
|
# These are build-time variables - to change them with the published Docker
|
||||||
|
# image, rebuild it with --build-arg (see README "Default UI locale").
|
||||||
|
#
|
||||||
|
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||||
|
# match any supported locale. Defaults to "en".
|
||||||
|
# Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl,
|
||||||
|
# pt, ro, ru, sk, tr, uk, zh
|
||||||
|
# An unsupported value falls back to "en".
|
||||||
|
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||||
|
|
||||||
|
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||||
|
# subpath (NEXT_PUBLIC_BASE_PATH) to avoid next-intl rewrite loops.
|
||||||
|
# Values: never (default) | always | as-needed
|
||||||
|
# NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Legacy Build-time Variables (still supported as fallback)
|
# Legacy Build-time Variables (still supported as fallback)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -118,3 +118,114 @@ jobs:
|
|||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
build-always:
|
||||||
|
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 }}
|
||||||
|
NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||||
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=always-${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=always-${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests-always
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests-always/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-always-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
path: /tmp/digests-always/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge-always:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-always
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests-always
|
||||||
|
pattern: digests-always-*
|
||||||
|
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 }}
|
||||||
|
flavor: |
|
||||||
|
suffix=-always,onlatest=true
|
||||||
|
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-always
|
||||||
|
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 }}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
name: Build Electron Desktop App
|
||||||
|
|
||||||
|
# Phase 1 of the VNCprodbuild rollout (~/.claude/skills/VNCprodbuild/SKILL.md
|
||||||
|
# on the machine that authored this - Phase 1 step 8). Builds the desktop
|
||||||
|
# shell (electron/) for macOS, Windows, and Linux on every release, or
|
||||||
|
# on-demand via workflow_dispatch for a one-off test build.
|
||||||
|
#
|
||||||
|
# Ships UNSIGNED. There's no Apple Developer ID or Windows code-signing cert
|
||||||
|
# yet (VNCprodbuild Phase 1 step 9 - both are human-owned purchases, not
|
||||||
|
# something CI can provide). CSC_IDENTITY_AUTO_DISCOVERY: "false" below stops
|
||||||
|
# electron-builder from probing for a macOS signing identity it won't find.
|
||||||
|
# Adding real certs later needs no rewrite here - just add CSC_LINK/
|
||||||
|
# CSC_KEY_PASSWORD (macOS) and/or WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD (Windows)
|
||||||
|
# as repo secrets and electron-builder picks them up automatically.
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||||
|
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 Next.js server
|
||||||
|
run: npm run build:standalone
|
||||||
|
|
||||||
|
- name: Bundle Electron main/preload
|
||||||
|
run: npm run build:electron
|
||||||
|
|
||||||
|
# Only Linux runners lack a display server by default - macOS/Windows
|
||||||
|
# GitHub-hosted runners can launch a real (if headless) GUI session
|
||||||
|
# without one.
|
||||||
|
- name: Install Xvfb (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||||
|
|
||||||
|
# Required gate (VNCprodbuild Phase 1 step 2) before any packaging or
|
||||||
|
# artifact-upload step below, on every OS in the matrix - a
|
||||||
|
# platform-specific regression in electron/main.ts (path handling,
|
||||||
|
# spawn behavior, etc.) should fail exactly the leg it breaks, not
|
||||||
|
# slip through because only one OS was ever smoke-tested.
|
||||||
|
- name: Run Electron smoke test (Linux, via Xvfb)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: xvfb-run --auto-servernum npm run test:electron
|
||||||
|
|
||||||
|
- name: Run Electron smoke test
|
||||||
|
if: runner.os != 'Linux'
|
||||||
|
run: npm run test:electron
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
run: npx electron-builder --config electron-builder.config.js --publish ${{ github.event_name == 'release' && 'always' || 'never' }}
|
||||||
|
|
||||||
|
- name: Upload artifact (workflow_dispatch)
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: vncmail-plus-desktop-${{ matrix.os }}
|
||||||
|
path: |
|
||||||
|
dist-electron-builds/*.dmg
|
||||||
|
dist-electron-builds/*.zip
|
||||||
|
dist-electron-builds/*.exe
|
||||||
|
dist-electron-builds/*.AppImage
|
||||||
|
dist-electron-builds/*.deb
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: PR Verify
|
||||||
|
|
||||||
|
# Required status check on `main` (Settings -> Branches). Mirrors the GitLab
|
||||||
|
# CI `verify` stage (.gitlab-ci.yml) so both remotes gate merges the same
|
||||||
|
# way: typecheck, lint, translations, and a real production build — no
|
||||||
|
# registry, no cluster, nothing that can be blocked by infra that's down.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run test:translations
|
||||||
|
- run: npm run build
|
||||||
|
env:
|
||||||
|
GIT_COMMIT: ${{ github.sha }}
|
||||||
+24
@@ -38,6 +38,14 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# electron (see electron/, scripts/build-electron.mjs, electron-builder.config.js)
|
||||||
|
/dist-electron/
|
||||||
|
/dist-electron-builds/
|
||||||
|
|
||||||
|
# playwright output
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
@@ -50,3 +58,19 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Sibling repos
|
# Sibling repos
|
||||||
/repos/
|
/repos/
|
||||||
|
|
||||||
|
# k8s deploy secrets (create from the matching overlay's secret.example.yaml)
|
||||||
|
/deploy/k8s/overlays/*/secret.yaml
|
||||||
|
|
||||||
|
# First-party plugin build output (rebuild with: npm run build:plugins).
|
||||||
|
# vnc/plugins/build/ is the staging dir the server installs from at startup
|
||||||
|
# (see lib/admin/bundled-plugins.ts) - built, never committed.
|
||||||
|
vnc/plugins/build/
|
||||||
|
vnc/plugins/*/node_modules/
|
||||||
|
vnc/plugins/*/dist/
|
||||||
|
vnc/plugins/smime/smime-vnc.zip
|
||||||
|
vnc/plugins/smime/smime.zip
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
electron-ai-local-index-result.png
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
# GitLab-CI dev→prod pipeline for VNCmail+ — GitOps via ArgoCD.
|
||||||
|
#
|
||||||
|
# Design:
|
||||||
|
# - MR into `dev`: verify only (typecheck/lint/unit test/build check). No
|
||||||
|
# push, no deploy — this is the multi-developer merge gate.
|
||||||
|
# - Push to `dev`: build+push an immutable `sha-<sha>` tag with Docker +
|
||||||
|
# docker-in-docker, then commit a one-line tag-bump into
|
||||||
|
# overlays/dev/image-tag/kustomization.yaml (`[skip ci]`). ArgoCD's
|
||||||
|
# `vncmail-dev` Application syncs it automatically.
|
||||||
|
# - Push to `main`: NEVER rebuilds. `main` only advances via
|
||||||
|
# `git merge --ff-only dev`, so main's HEAD commit already has a built
|
||||||
|
# image. This job just bumps overlays/prod/image-tag/kustomization.yaml
|
||||||
|
# to point at that same tag. The actual promotion gate is a HUMAN
|
||||||
|
# clicking Sync on the `vncmail-prod` ArgoCD Application.
|
||||||
|
#
|
||||||
|
# Deliberately single-platform (linux/amd64) — this pipeline serves two
|
||||||
|
# known amd64 microk8s clusters, not public multi-arch distribution (that's
|
||||||
|
# what the GHCR release workflows are for, untouched by this file).
|
||||||
|
#
|
||||||
|
# Prerequisite this file assumes:
|
||||||
|
# - A GitLab Runner with Docker-in-Docker service support (Kubernetes or
|
||||||
|
# Docker executor). The `docker:28.4.0-dind` service requires privileged
|
||||||
|
# mode on most Kubernetes executors.
|
||||||
|
# - Either "allow this job token to push to this project" enabled
|
||||||
|
# (Settings → CI/CD → Job token permissions), OR a project access token
|
||||||
|
# with `write_repository` scope in $GITLAB_PUSH_TOKEN. The bump jobs
|
||||||
|
# try CI_JOB_TOKEN first (see the script).
|
||||||
|
#
|
||||||
|
# deploy/k8s/ca/ (the EJBCA internal CA) is never referenced anywhere below,
|
||||||
|
# and neither ArgoCD Application in deploy/argocd/ points at it — that stays
|
||||||
|
# a fully manual, human-only runbook (see deploy/k8s/ca/README.md).
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- verify
|
||||||
|
- build
|
||||||
|
- bump-dev
|
||||||
|
- bump-prod
|
||||||
|
|
||||||
|
variables:
|
||||||
|
IMAGE: $CI_REGISTRY_IMAGE
|
||||||
|
GIT_STRATEGY: clone
|
||||||
|
DOCKER_DRIVER: overlay2
|
||||||
|
# DinD service is reached at the `docker` alias (set explicitly on the
|
||||||
|
# service below), not localhost. TLS disabled so the daemon listens on
|
||||||
|
# plaintext 2375 — same pattern as the working vnc-localidp pipeline.
|
||||||
|
DOCKER_HOST: tcp://docker:2375
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# verify — required check on every MR into dev. No registry, no cluster.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
verify:
|
||||||
|
stage: verify
|
||||||
|
image: node:24-alpine
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||||
|
script:
|
||||||
|
- npm ci
|
||||||
|
- npm run typecheck
|
||||||
|
- npm run lint
|
||||||
|
- npm run test:translations
|
||||||
|
- npm run build
|
||||||
|
# test:integration is deliberately NOT here — it spins up a real Stalwart
|
||||||
|
# fixture via docker-compose, which needs an actual Docker daemon this
|
||||||
|
# runner's Kubernetes executor doesn't provide without privileged mode
|
||||||
|
# (see the build job below). Candidate for a separate scheduled job on a
|
||||||
|
# differently-configured runner, not a blocker on every MR.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build — push to dev only. Builds once; main never rebuilds (see header).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
image: docker:28.4.0
|
||||||
|
services:
|
||||||
|
- name: docker:28.4.0-dind
|
||||||
|
alias: docker
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
|
||||||
|
before_script:
|
||||||
|
- until docker info; do sleep 1; done
|
||||||
|
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
|
||||||
|
script:
|
||||||
|
- >
|
||||||
|
docker build
|
||||||
|
--build-arg GIT_COMMIT=$CI_COMMIT_SHA
|
||||||
|
-t "$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
|
||||||
|
-t "$IMAGE:dev-latest"
|
||||||
|
.
|
||||||
|
- docker push "$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
|
||||||
|
- docker push "$IMAGE:dev-latest"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# bump-dev — no cluster access. Commits the just-built tag into the overlay
|
||||||
|
# ArgoCD watches; ArgoCD's automated sync does the actual apply.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
bump-dev:
|
||||||
|
stage: bump-dev
|
||||||
|
# alpine/git:2.47.0 was never published on Docker Hub — the 2.47.x line
|
||||||
|
# starts at 2.47.1. Using 2.47.2 (latest 2.47.x).
|
||||||
|
image: alpine/git:2.47.2
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
|
||||||
|
script:
|
||||||
|
- TAG="sha-$CI_COMMIT_SHORT_SHA"
|
||||||
|
- |
|
||||||
|
cat > deploy/k8s/overlays/dev/image-tag/kustomization.yaml <<EOF
|
||||||
|
# Owned by CI (bump-dev job in .gitlab-ci.yml) - regenerated every
|
||||||
|
# push to dev. Do not hand-edit; edits here get overwritten.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||||
|
kind: Component
|
||||||
|
images:
|
||||||
|
- name: vncmail-plus
|
||||||
|
newName: $IMAGE
|
||||||
|
newTag: $TAG
|
||||||
|
EOF
|
||||||
|
- git config user.name "vncmail-ci"
|
||||||
|
- git config user.email "ci@vnc.biz"
|
||||||
|
- git add deploy/k8s/overlays/dev/image-tag/kustomization.yaml
|
||||||
|
- |
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No change (tag already pinned) - nothing to commit"
|
||||||
|
else
|
||||||
|
git commit -m "chore(deploy): pin dev to $TAG [skip ci]"
|
||||||
|
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:dev
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# bump-prod — no cluster access, no rebuild. Points overlays/prod at the
|
||||||
|
# exact tag already running on dev. Does NOT deploy anything: vncmail-prod's
|
||||||
|
# ArgoCD Application has manual sync, so this only prepares what a human
|
||||||
|
# would be syncing, it doesn't sync it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
bump-prod:
|
||||||
|
stage: bump-prod
|
||||||
|
image: alpine/git:2.47.2
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
||||||
|
script:
|
||||||
|
- TAG="sha-$CI_COMMIT_SHORT_SHA"
|
||||||
|
- echo "main advanced to $CI_COMMIT_SHA (must be a dev commit, ff-only) - that image already exists as $IMAGE:$TAG"
|
||||||
|
- |
|
||||||
|
cat > deploy/k8s/overlays/prod/image-tag/kustomization.yaml <<EOF
|
||||||
|
# Owned by CI (bump-prod job in .gitlab-ci.yml) - regenerated every
|
||||||
|
# push to main. Do not hand-edit; edits here get overwritten. Bumping
|
||||||
|
# this is NOT the same as deploying it - vncmail-prod's ArgoCD
|
||||||
|
# Application has manual sync, see the note in the parent
|
||||||
|
# kustomization.yaml.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||||
|
kind: Component
|
||||||
|
images:
|
||||||
|
- name: vncmail-plus
|
||||||
|
newName: $IMAGE
|
||||||
|
newTag: $TAG
|
||||||
|
EOF
|
||||||
|
- git config user.name "vncmail-ci"
|
||||||
|
- git config user.email "ci@vnc.biz"
|
||||||
|
- git add deploy/k8s/overlays/prod/image-tag/kustomization.yaml
|
||||||
|
- |
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No change (tag already pinned) - nothing to commit"
|
||||||
|
else
|
||||||
|
git commit -m "chore(deploy): point prod overlay at $TAG (not synced - manual gate in ArgoCD) [skip ci]"
|
||||||
|
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:main
|
||||||
|
fi
|
||||||
+483
@@ -1,5 +1,488 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.7.9 (2026-08-07)
|
||||||
|
|
||||||
|
### Bug Fixes (Phase 1 — VNCmailgraph audit)
|
||||||
|
|
||||||
|
- **Mail**: Network transport failures now throw `TransportError` instead of returning empty results, so offline/network-down is distinguishable from an empty folder (#C1)
|
||||||
|
- **Mail**: Push handler now refreshes contacts and files on remote state changes (#H1)
|
||||||
|
- **Calendar**: Recurrence expansion IDs use `::occurrence::` delimiter to prevent collision with shared-event prefixes (#C2)
|
||||||
|
- **Calendar**: Cross-account event aggregation now deduplicates by UID + recurrenceId, preventing phantom duplicates (#C3)
|
||||||
|
- **Calendar**: `calendarTasksEnabled` admin policy now enforced at runtime, not just in settings UI (#H13)
|
||||||
|
- **Tasks**: All task mutations (update, delete, toggle) now have error handling with store error state (#H14)
|
||||||
|
- **Settings**: `updateSetting()` now checks admin policy lock before writing; `force` opt-in for legitimate bypassers (#C7)
|
||||||
|
- **Settings**: `autoSelectReplyIdentity` now defaults to `true` — auto-identity selection on by default (#H18)
|
||||||
|
- **Templates**: HTML template bodies are now sanitized with DOMPurify on import to prevent stored XSS (#H7)
|
||||||
|
- **Auth**: User authentication endpoints now rate-limited — 10 attempts per (IP + username) per 15 minutes (#H3)
|
||||||
|
- **Auth**: Admin sessions now support token revocation via JTI blacklist on logout (#C4)
|
||||||
|
- **Auth**: Secure cookie flag now derived from `x-forwarded-proto`, not `NODE_ENV` (#H8)
|
||||||
|
- **Auth**: OAuth token exchange error logs no longer leak `access_token` (#H4)
|
||||||
|
- **Auth**: `isHashed()` no longer accepts bcrypt prefixes — scrypt-only, preventing lockout from bcrypt passwords (#H9)
|
||||||
|
- **Push**: WS→SSE fallback now awaits state snapshot before reconciliation to prevent missed deliveries (#H2)
|
||||||
|
- **Push**: Offline event handler added — push transports pause when browser goes offline, reconnect on online (#C8)
|
||||||
|
- **Index**: FTS5 schema-drop now logs a warning so operators know a rebuild is needed (#C6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.7.8 (2026-07-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Unified Mailbox**: Account-bounded Unified Mailbox with opt-in cross-account aggregation (#509)
|
||||||
|
- **Unified Mailbox**: Search in the unified views
|
||||||
|
- **Unified Mailbox**: Live unified/All-Mail counters for shared and group accounts
|
||||||
|
- **Mail**: Message-list category tabs
|
||||||
|
- **Mail**: Drag-and-drop reorder for all folders
|
||||||
|
- **Mail**: Collapse quoted reply text behind a "..." toggle (#480)
|
||||||
|
- **Mail**: Bulk Not-Spam action in the junk selection toolbar
|
||||||
|
- **Mail**: Unread count badge on the favicon
|
||||||
|
- **Mail**: Message spacing setting (auto/always/edge-to-edge)
|
||||||
|
- **Mail**: Open external links in a new tab (safely)
|
||||||
|
- **Mail**: Strip external `url()`/`@import` from `<style>` blocks in the sanitizer (#457)
|
||||||
|
- **Composer**: Text color picker in the composer toolbar
|
||||||
|
- **Composer**: Contact groups as single expandable recipient chips
|
||||||
|
- **Composer**: Drag-to-reorder To/Cc/Bcc recipient chips (#593)
|
||||||
|
- **Composer**: Auto-detect paragraph text direction by default
|
||||||
|
- **Templates**: HTML template support
|
||||||
|
- **Vacation**: HTML body support in the vacation responder
|
||||||
|
- **Send**: 'Send now' action on the send-delay toast
|
||||||
|
- **Accounts**: Remove a specific account from the switcher
|
||||||
|
- **Settings**: "Refresh cached data" recovery action
|
||||||
|
- **i18n**: Full Arabic (ar) translation with RTL support
|
||||||
|
- **Login**: `LOGIN_SHOW_TOTP` and `LOGIN_SHOW_VERSION` config flags (#520)
|
||||||
|
- **Docker**: `NEXT_PUBLIC_LOCALE_PREFIX` build argument
|
||||||
|
- **Plugins**: `ui.rerenderFetchedEmails` method (#668)
|
||||||
|
- **Plugins**: `onEmailsFetched` and `onSearchResults` hooks and `getSomeEmails` JMAP method
|
||||||
|
- **Plugins**: `onRecipientChipsChange` hook
|
||||||
|
- **Plugins**: `webauthn.getOrCreate` API method
|
||||||
|
- **Plugins**: Download files generated by a plugin (with `ui:download-file` consent permission)
|
||||||
|
- **Plugins**: Submit mail without moving to a mailbox and import-to-mailbox APIs
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Render the email body on DOM parse instead of iframe load (#635)
|
||||||
|
- **Mail**: Keep sidebar tag counts in step with read/unread changes
|
||||||
|
- **Mail**: Enable thread expansion in the focused list
|
||||||
|
- **Mail**: Show the quote bar in email replies
|
||||||
|
- **Mail**: Honor part-type fallback when quoting replies (#649)
|
||||||
|
- **Mail**: Detect typing inside the quoted-HTML shadow island (#654)
|
||||||
|
- **Mail**: Keep `target`/`rel` on links in plain-text message bodies and open signature links in a new tab
|
||||||
|
- **Accounts**: Eliminate the full-screen flash when switching accounts (including cached accounts)
|
||||||
|
- **Accounts**: Recognize canonicalized login usernames in the account-switch guard
|
||||||
|
- **Auth**: Guard account switch against slot→token desync and basic-auth identity mismatches
|
||||||
|
- **Auth**: End refresh loops on sign-out and back off failed retries
|
||||||
|
- **OAuth**: Harden OIDC discovery (timeout, retry, serve-stale)
|
||||||
|
- **JMAP**: Preserve POST across redirects in the Stalwart JMAP passthrough (#627)
|
||||||
|
- **JMAP**: File the post-send message with a full `mailboxIds` replacement
|
||||||
|
- **JMAP**: Generate the Message-ID client-side using the sender's domain
|
||||||
|
- **Identity**: Sync the default sender identity per account (#507)
|
||||||
|
- **Attachments**: Download/view attachments on cross-account All-Mail messages
|
||||||
|
- **Shared folders**: Route batch actions to the owner account
|
||||||
|
- **Templates**: Insert a mail template at the caret in replies instead of prepending (#539)
|
||||||
|
- **Templates**: Keep the signature when inserting a template (#621)
|
||||||
|
- **Templates**: Hide template buttons when templates are disabled
|
||||||
|
- **Calendar**: Honor "Show time in month view" on mobile instead of forcing dots (#666)
|
||||||
|
- **Calendar**: Classify self-organized imported events as editable
|
||||||
|
- **Contacts**: Assign a UID to contact cards on creation (#644)
|
||||||
|
- **Spam**: Stop HELO `spf=none` from downgrading a MAIL FROM `spf=pass` (#650)
|
||||||
|
- **Drafts**: Label the close-dialog draft button with the generic Save
|
||||||
|
- **RTL**: Flip JS-positioned popovers and anchor floating menus with logical start/end
|
||||||
|
- **RTL**: Isolate Latin address text from RTL bidi reordering and force LTR identity options
|
||||||
|
- **i18n**: Register Arabic messages in the client IntlProvider
|
||||||
|
- **i18n**: Fix the Hebrew Drafts folder label
|
||||||
|
- **i18n**: Add missing translation keys across 22 locales
|
||||||
|
- **Deps**: Bump `dompurify` to 3.4.12 and `next-intl` to 4.13.3
|
||||||
|
|
||||||
|
## 1.7.7 (2026-07-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: `ui.rerenderEmail` API and restyled read-receipt banner
|
||||||
|
- **Plugins**: New hooks — `onBeforeBlobUpload`, `onBeforeDraftAutoSave`, `onBeforeEditDraft` (#586)
|
||||||
|
- **Plugins**: `ui.prompt` dialog and first-class settings-section tabs
|
||||||
|
- **Calendar**: Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
|
||||||
|
- **i18n**: Hebrew locale with full RTL support
|
||||||
|
- **i18n**: Slovak translation
|
||||||
|
- **i18n**: User-selectable regional date format
|
||||||
|
- **Contacts**: Enable trusted-senders address book sync by default when contacts are available
|
||||||
|
- **Mail**: Pin emails to the top of the folder list
|
||||||
|
- **Mail**: Setting to disable the tag-color row tint in the message list
|
||||||
|
- **Mail**: Click the sender avatar to select a message/thread (Thunderbird-style)
|
||||||
|
- **Accounts**: Pin the default account on top and drag-to-reorder the account switcher
|
||||||
|
- **Composer**: Recipient autocomplete from Sent, with on-demand server search
|
||||||
|
- **Composer**: Preselect the identity of the active mailbox for new messages
|
||||||
|
- **Email**: Send a quick reply with Ctrl/Cmd+Enter
|
||||||
|
- **Headers**: Parse Stalwart spam headers
|
||||||
|
- **Login**: Configurable logo size and hideable heading/subtitle
|
||||||
|
- **PWA**: Apple Touch icons for the iOS home screen
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Hide Files when the account lacks the filenode capability (#563)
|
||||||
|
- **Mail**: Keep advanced search filters applied when switching folders (#553)
|
||||||
|
- **Mail**: Keep the email list scrollable when the bottom reading pane is enabled with no conversation selected
|
||||||
|
- **Mail**: Route keyword writes to the email's own account in unified view
|
||||||
|
- **Mail**: Render emails that set `height:100%` on a wrapper element
|
||||||
|
- **Mail**: Hide images that fail to load
|
||||||
|
- **Mail**: Storage quota not shown with Stalwart (#577)
|
||||||
|
- **Spam**: Hide the spam action in Sent, Drafts and Scheduled
|
||||||
|
- **Spam**: Fix stale folder counters and open message after spam actions
|
||||||
|
- **Composer**: Wait for in-flight attachment uploads before sending
|
||||||
|
- **Composer**: Only commit a recipient on Space when the input is a valid email (#571)
|
||||||
|
- **Composer**: Attachment reminder now ignores quoted text on reply/forward (#570)
|
||||||
|
- **Calendar**: Store the event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE
|
||||||
|
- **Calendar**: Strike through cancelled events and mute their reminders (#572)
|
||||||
|
- **Calendar**: Use `calendarAddress`/`organizerCalendarAddress` for scheduling, drop retired `sendTo`/`replyTo` (#500)
|
||||||
|
- **Auth**: Keep the session when the auth server is briefly unreachable
|
||||||
|
- **Shortcuts**: Make keyboard shortcuts layout-agnostic and map by physical position
|
||||||
|
- **Shortcuts**: Don't toggle mailbox subfolders on Arrow keys while typing
|
||||||
|
- **Contacts**: Clear the photo on the server by sending `media: null` when removed
|
||||||
|
- **Plugins**: Preserve the settings slot and privileged tier
|
||||||
|
- **Pro**: Prompt to save or discard a draft when closing a compose tab via the tab-bar X
|
||||||
|
- **Pro**: Show the Edit button on draft emails opened in a new tab
|
||||||
|
- **List**: Shift-click on the checkbox extends the selection (range)
|
||||||
|
- **CSP**: Allow external/data fonts so email webfonts render
|
||||||
|
- **Notifications**: Brand push notifications with the configured PWA icon
|
||||||
|
- **Notifications**: Notification sound preview — base-path prefix and longer default beep
|
||||||
|
- **Unsubscribe**: Send `mailto:` unsubscribe ourselves instead of via the OS handler
|
||||||
|
- **Branding**: Apply per-domain favicon override in root metadata (#585)
|
||||||
|
- **Settings**: Load the trusted-senders address book on the settings page so the count isn't 0
|
||||||
|
- **Setup**: Clone source when `setup.sh` runs detached from a checkout (#518)
|
||||||
|
- **Server**: Use a callable `.get` to detect `Headers` in `pickRequestHost`
|
||||||
|
|
||||||
|
## 1.7.6 (2026-06-28)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
|
||||||
|
- **Plugins**: Plugin hooks for email details, headers, and source
|
||||||
|
- **Mail**: Option to hide the total message count on folders (#498)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
|
||||||
|
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
|
||||||
|
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
|
||||||
|
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
|
||||||
|
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
|
||||||
|
- **Auth**: Support MFA login via the structured auth endpoint
|
||||||
|
- **Admin**: Show all built-in themes in the admin theme controls (#496)
|
||||||
|
- **i18n**: Add missing translation keys across 19 locales
|
||||||
|
|
||||||
|
## 1.7.5 (2026-06-24)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: Cross-account "All accounts" views with full group/shared-account support
|
||||||
|
- **Mail**: Per-account "All Mail" folder selection
|
||||||
|
- **Mail**: "Download all" button to bundle attachments into a zip (#466)
|
||||||
|
- **Mail**: Return to the list after deleting or marking the open message unread — configurable (default on)
|
||||||
|
- **Mail**: Collapse-all-threads action in thread-list selection
|
||||||
|
- **Calendar**: Option to disable the calendar
|
||||||
|
- **Composer**: Send-now button on scheduled/delayed messages
|
||||||
|
- **Composer**: Email a contact or group via the in-app composer
|
||||||
|
- **Composer**: Split a pasted address list into recipient chips
|
||||||
|
- **Contacts**: "New address book" creation UI (#415)
|
||||||
|
- **OAuth**: `OAUTH_AUTHORIZE_URL` to override the authorize endpoint
|
||||||
|
- **i18n**: Farsi (fa) locale — complete (2654 strings)
|
||||||
|
- **i18n**: Romanian (ro) locale
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Composer**: Keep HTML signature styling in the editor and on send
|
||||||
|
- **Composer**: Guard Send against double-submit
|
||||||
|
- **Composer**: Strip display names from the `EmailSubmission` envelope addresses
|
||||||
|
- **Calendar**: Disable iMIP scheduling on calendar import (#411)
|
||||||
|
- **Mail**: Localize special-folder names by JMAP role (#404)
|
||||||
|
- **Mail**: Block remaining email tracking vectors (#457)
|
||||||
|
- **Mail**: Route counter and unread updates to the email's own account in aggregate views
|
||||||
|
- **Mail**: Fix blank space in plain-text emails
|
||||||
|
- **Mail**: Fix toolbar re-render when opening emails
|
||||||
|
- **Mail**: Truncate long subjects so they don't overlap the timestamp
|
||||||
|
- **Mail**: Strip reply/forward prefixes followed by a full-width colon
|
||||||
|
- **Mail**: Add breathing room between the unread dot and the avatar
|
||||||
|
- **Mail**: Isolate per-account state snapshots from leakage and mutation
|
||||||
|
- **Mail**: Cap filename tokens at the full 200-char limit
|
||||||
|
- **Spam**: Fetch mailboxes with `accountId` in `markAsSpam`
|
||||||
|
- **Filters**: Load mailboxes when opened directly (#485)
|
||||||
|
- **Settings**: Surface server errors on password change and TOTP toggle
|
||||||
|
- **Send now**: Gate the toolbar label and translate `send_now` across locales
|
||||||
|
- **Directory**: Fix fetching display names
|
||||||
|
- **Push**: Reap only relay-confirmed-dead leftover subscriptions
|
||||||
|
- **i18n**: Add the missing fa locale to the client `IntlProvider` messages map
|
||||||
|
- **i18n**: Add missing translation keys across 19 locales
|
||||||
|
|
||||||
|
## 1.7.4 (2026-06-15)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: New "All Mail" view across folders and accounts
|
||||||
|
- **Mail**: Edit contact directly from the email viewer contact sidebar
|
||||||
|
- **Calendar**: Recurrence editor, set-default calendar, and timezone-aware calendar queries
|
||||||
|
- **Calendar**: Agenda plugin sidecar
|
||||||
|
- **Composer**: Email display name support
|
||||||
|
- **Composer**: Drag-and-drop recipient chips between To/CC/BCC fields, with the address shown in the drag preview
|
||||||
|
- **Composer**: Avatars in recipient autocomplete suggestions, including directory users
|
||||||
|
- **Files**: JMAP file/folder sharing in the Files app (#408)
|
||||||
|
- **Auth**: QR-code SSO login and device pairing between webmail and the mobile app
|
||||||
|
- **Auth**: Require re-authentication for device pairing and SSO
|
||||||
|
- **Accounts**: Manage shared/group account settings from the Accounts page
|
||||||
|
- **Setup**: Opt-in telemetry in the web setup wizard
|
||||||
|
- **Mail**: Persist the email detail sidebar state
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Preserve line breaks in the generated `text/plain` alternative (#421)
|
||||||
|
- **Mail**: Fix inconsistent threading of email messages in the inbox and folders
|
||||||
|
- **Mail**: Stop draft emails from being marked as unread
|
||||||
|
- **Mail**: Prevent wide email tables from rendering with rotated headers (#409)
|
||||||
|
- **Mail**: Preserve the folder list when a mailbox refetch hits the concurrent-request limit
|
||||||
|
- **Mail**: Correct dark-mode background-image inversion and height clipping in the email viewer
|
||||||
|
- **Calendar**: Dedupe scheduling emails and use Stalwart-compatible calendar filters
|
||||||
|
- **Calendar**: Redesign the custom recurrence editor to match the modal UI
|
||||||
|
- **Files**: Don't send the connected-account key as the JMAP `accountId` when sharing files (#408)
|
||||||
|
- **Routing**: Strip the build-time `basePath` from `router.push` redirects after login (#390)
|
||||||
|
- **Nav**: Open recent contact emails at `/` instead of 404ing on `/mail`
|
||||||
|
- **Nav**: Hide the Add App button when `sidebarAppsEnabled` is false
|
||||||
|
- **Settings**: Move the "Plain Text Only" setting from Reading to Composing (#422)
|
||||||
|
- **Privacy**: Make telemetry opt-in
|
||||||
|
- **UI**: Fix the context menu being invisible on first right-click after page load
|
||||||
|
- **Admin**: Remove the JMAP status from the admin dashboard
|
||||||
|
- **i18n**: Add missing translation keys across 17 locales
|
||||||
|
|
||||||
|
## 1.7.3 (2026-06-04)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: Inline attachment preview — reliable MIME detection with inline PDF on desktop and mobile
|
||||||
|
- **Mail**: Preview composer attachments inline (click to open)
|
||||||
|
- **Mail**: Preview `.eml` (`message/rfc822`) attachments like an email
|
||||||
|
- **Mail**: Read receipts (MDN, RFC 8098)
|
||||||
|
- **Mail**: Editable, layout-preserving quote island when replying
|
||||||
|
- **Mail**: Surface the most severe SPF result and hide the "via" badge on spoofed mail
|
||||||
|
- **Calendar**: Per-viewer colors for shared calendars (#345)
|
||||||
|
- **Filters**: Extended filter rules — attachment field and multi-value conditions
|
||||||
|
- **Settings**: New built-in themes — Aurora Glass and Elastic
|
||||||
|
- **Settings**: Theme cards render as a mini mailbox mockup from theme colors, with light/dark variant chips
|
||||||
|
- **Plugins**: Localizable sandboxed plugins (manifest locales + `api.i18n.t`)
|
||||||
|
- **Plugins**: `/api/translate` proxy and email body exposed to plugins
|
||||||
|
- **Admin**: Toggle for search-engine indexing (robots)
|
||||||
|
- **Admin**: `passwordHashFile` in `admin.json`
|
||||||
|
- **Admin**: `sessionSecretFile` and `oauthClientSecretFile` for file-based secrets in JSON config
|
||||||
|
- **PWA**: Configurable install screenshots (per-domain)
|
||||||
|
- **i18n**: Hungarian locale support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Files**: Store Files as real `FileNode` hierarchy, migrate legacy flat-named files on load, and list folders via `FileNode/get` so they are visible (#379)
|
||||||
|
- **Files**: Treat a blob-less `FileNode` as the only folder signal and migrate legacy dir-markers
|
||||||
|
- **Mail**: Empty Trash for shared and group folders (#387)
|
||||||
|
- **Mail**: Move mail from a shared group inbox to a personal inbox (#375)
|
||||||
|
- **Mail**: Preserve the HTML signature when sending a quick reply
|
||||||
|
- **Mail**: Stop body clipping under the fold when the email sets `html`/`body` `height: 100%`
|
||||||
|
- **Mail**: Drop single-letter `R:`/`I:` subject prefix tokens and deduplicate localized reply/forward prefixes
|
||||||
|
- **Mail**: No more 404 console spam for missing sender favicons
|
||||||
|
- **Auth**: Discover OIDC metadata server-side to avoid CORS failures (#382)
|
||||||
|
- **Send**: Route the Sent copy to the shared-mailbox account on per-identity send
|
||||||
|
- **Routing**: Honour `basePath` in the plugin sandbox, `http.post` proxy, and branding
|
||||||
|
- **i18n**: Localize the PWA install prompt, reply/forward quote header (incl. sender address), `<html lang>`, and per-locale `<head>` description; add missing `settings.folders.role_memos` key
|
||||||
|
- **Themes**: Plugin slot iframes inherit host font and color tokens
|
||||||
|
- **Theme**: Gate preview "open in new tab" on inline-safe MIME types
|
||||||
|
- **Appearance**: Move Themes settings into the Appearance category with a distinct tab icon; clicking the active theme is a no-op
|
||||||
|
- **UI**: Fix invisible dark-mode borders (border token collided with secondary)
|
||||||
|
- **UI**: Remove the 16px empty strip beside the collapsed sidebar
|
||||||
|
- **UI**: Align top bars to a uniform `h-14` height and the account selector header to the search/reply toolbars
|
||||||
|
- **UI**: Close pane gaps by centering the resize handle on the seam
|
||||||
|
- **Settings**: Fix section gears permanently hijacking the active tab
|
||||||
|
|
||||||
|
## 1.7.2 (2026-05-28)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: Scheduled send and send delay (#322)
|
||||||
|
- **Mail**: Drag emails out to the file explorer as `.eml`
|
||||||
|
- **Mail**: Import emails from `.zip` archives
|
||||||
|
- **Mail**: "Move to Trash and mark as read" delete action (#323)
|
||||||
|
- **Mail**: Include group inboxes in the unified mailbox view (#328)
|
||||||
|
- **Mail**: Locale-aware date format in the email list with a preset picker (#331)
|
||||||
|
- **Mail**: Allow drag-and-drop into shared mailboxes
|
||||||
|
- **Composer**: Ctrl/Cmd+Enter sends the open draft
|
||||||
|
- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames
|
||||||
|
- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template
|
||||||
|
- **Settings**: Post-export action (keep / archive / trash)
|
||||||
|
- **Settings**: Template for multi-email `.zip` filenames
|
||||||
|
- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332)
|
||||||
|
- **Admin**: Policy-controlled push relay URL with optional user lock
|
||||||
|
- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329)
|
||||||
|
- **Mail**: Report real upload progress with XHR progress events (#333)
|
||||||
|
- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews
|
||||||
|
- **Mail**: Match user-avatar treatment on quick reply
|
||||||
|
- **Email viewer**: Stop shattering table cells with `word-break: break-word`
|
||||||
|
- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer
|
||||||
|
- **Composer**: Stop closing the form when editing any field
|
||||||
|
- **Pro**: Keep the empty viewer pane visible in the split layout
|
||||||
|
- **Pro**: Prevent an empty main pane when reordering tabs across panes
|
||||||
|
- **Mobile**: Collapse focus mail layout to multi-line
|
||||||
|
- **Mobile**: Keep a gutter on bare-HTML and plain-text emails
|
||||||
|
- **Calendar**: Align continued multi-week events with the week's left edge
|
||||||
|
- **Calendar**: Show the end date in the event popover for multi-day events (#318)
|
||||||
|
- **Calendar**: Convert `recurrenceRules` to singular in batch create
|
||||||
|
- **Calendar**: Handle malformed event dates (#316)
|
||||||
|
- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters
|
||||||
|
- **Routing**: Prefix remaining `<img>`, favicon, and WebDAV URLs with `basePath` (#319)
|
||||||
|
- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments
|
||||||
|
- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Add missing translation keys across 16 locales
|
||||||
|
|
||||||
|
## 1.7.1 (2026-05-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Admin**: Expose PWA branding fields in the admin Branding tab
|
||||||
|
- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Preserve inline images when replying (#163)
|
||||||
|
- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313)
|
||||||
|
- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop
|
||||||
|
|
||||||
|
## 1.7.0 (2026-05-21)
|
||||||
|
|
||||||
|
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **Plugins**: Plugins now run inside a null-origin iframe sandbox and talk to the host over a postMessage RPC bridge. The in-process plugin runtime is gone; the bundled in-tree plugins have been migrated. Third-party plugins built against the old in-process API need to be ported to the sandboxed runtime.
|
||||||
|
- **Plugins**: Server-managed bundles must be Ed25519-signed by the host and approved by an admin before they load. The host public key is served from `/api/plugin-signing-pubkey` and each bundle response carries the signature in the `X-Bundle-Signature` header. User-uploaded bundles still load unsigned, but managed marketplace and dev-folder bundles do not.
|
||||||
|
- **Plugins**: `bundleHash` is now a full SHA-256 over the bundle. Legacy short hashes are migrated on first load; any out-of-band tooling that pinned the old hash format needs to be updated.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Pro**: Tabbed shell with drag-to-reorder, drag-to-edge to split, side-by-side panes, and pane-aware responsive layout with a scoped sidebar overlay
|
||||||
|
- **Pro**: Auto-redirect to the Pro shell when Pro mode is on; `proInterface` is kept per-device instead of syncing
|
||||||
|
- **Pro**: Multi-account mail sidebar with client routing and a per-account mailbox cache
|
||||||
|
- **Pro**: Unified mailbox always visible, with full-text search
|
||||||
|
- **Pro**: Cross-account email moves
|
||||||
|
- **Pro**: Multi-account calendar sidebar split into owned vs shared per account
|
||||||
|
- **Pro**: Multi-account contacts and a cross-account file picker
|
||||||
|
- **Pro**: Composer From dropdown grouped by account
|
||||||
|
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
|
||||||
|
- **Plugins**: Marketplace update flow for installed plugins and themes
|
||||||
|
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
|
||||||
|
- **Setup**: Warn when the JMAP URL points at a local-only host
|
||||||
|
- **Account**: List and reorder logged-in accounts from settings (#282)
|
||||||
|
- **Mail**: Mobile handoff page with JMAP authentication verification for cross-device OAuth
|
||||||
|
- **Mail**: Pluggable reply/forward quote header (#295)
|
||||||
|
- **Calendar**: Support multiple flexible event reminders (#170)
|
||||||
|
- **Admin**: Expose PWA, app identity, and extension directory keys in the JSON config (#312)
|
||||||
|
- **Admin**: Surface OAuth scope settings and wire up orphaned admin policy gates
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Plugins**: Pin parent origin in the iframe bridge to block cross-frame postMessage
|
||||||
|
- **Plugins**: Ignore plugin-supplied `target` in `ui.openExternalUrl` to block host-frame hijack
|
||||||
|
- **Plugins**: Validate plugin/theme id in marketplace install to block path traversal
|
||||||
|
- **Plugins**: Prevent plugin config from leaking to non-admin users
|
||||||
|
- **Admin**: Gate admin routes against cross-origin CSRF
|
||||||
|
- **Auth**: Bind Stalwart auth context to the credential, not the cookie-claimed username
|
||||||
|
- **Auth**: Validate OAuth discovery endpoints against SSRF
|
||||||
|
- **Mail**: Tighten HTML sanitization at plain-text email, signature, and i18n render sites
|
||||||
|
- **Mail**: Block script-bearing MIME types from inline attachment preview
|
||||||
|
- **Mail**: Escape print-window fields and re-sanitize body to block XSS
|
||||||
|
- **S/MIME**: Stop persisting passphrases in `sessionStorage`
|
||||||
|
- **API**: Correct regex for valid API POST path validation
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Serialize draft autosave with send to stop replies stalling in Drafts (#303)
|
||||||
|
- **Mail**: Omit empty cc/bcc from `Email/set` so the server does not emit a bare `Cc:` header (#301)
|
||||||
|
- **Mobile**: Allow adding contacts from the mail recipient popover (#306)
|
||||||
|
- **Mobile**: Prevent dual-scroll and use full width for mail content
|
||||||
|
- **Mobile**: OAuth handoff flow
|
||||||
|
- **Calendar**: Scope iCal subscriptions per JMAP account; fix refresh and clear
|
||||||
|
- **Calendar**: iCal subscription refresh, rollback, and URL normalization
|
||||||
|
- **Calendar**: Show avatars in the calendar/address book sharing menu
|
||||||
|
- **Contacts**: Normalize malformed contact photo data URIs (#307)
|
||||||
|
- **Identity**: Clear identity signature fields when emptied
|
||||||
|
- **Identity**: Show size cap on identity signature fields
|
||||||
|
- **Identity**: Allow table-based layouts in the HTML signature sanitizer
|
||||||
|
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
|
||||||
|
- **Plugins**: Sync plugin slot iframe height with reported content height
|
||||||
|
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
|
||||||
|
- **Plugins**: Trust the directory version on marketplace install and update
|
||||||
|
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
|
||||||
|
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
|
||||||
|
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
|
||||||
|
- **Routing**: Respect server-resolved locale on first visit (#309)
|
||||||
|
- **Routing**: Split app into `(main)`/`(sandbox)` route groups so the plugin iframe hydrates properly
|
||||||
|
- **Files**: Stop parent directory navigation from jumping to root
|
||||||
|
- **Build**: Stop pulling `node:dns` into the client bundle via OAuth discovery
|
||||||
|
- **UI**: Toggle recipient popover when clicking the name again
|
||||||
|
- **UI**: Remove white halo around photo avatars
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Add missing translation keys across 16 locales
|
||||||
|
|
||||||
|
## 1.6.7 (2026-05-17)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Contacts**: vCard 4.0 parsing and generation support
|
||||||
|
- **Admin**: Master-user impersonation route with `app-top-banner` plugin slot rendered on every authenticated page
|
||||||
|
- **Admin**: Allow admin password overwrite during setup recovery
|
||||||
|
- **Setup**: HTTPS requirement warning in the setup wizard
|
||||||
|
- **Mobile**: Show details toggle and expandable panel for sender info
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **Calendar**: Speed up calendar invitation banner load
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Mail**: Sandbox thread email HTML in `srcDoc` iframe with a CSP `<meta>` tag
|
||||||
|
- **Admin**: Redact sensitive config secrets from the admin API response
|
||||||
|
- **Admin**: Make impersonation cookies session-only
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Auth**: Read `OAUTH_SCOPES` at runtime instead of build time
|
||||||
|
- **Auth**: Use a relative `Location` header in redirects
|
||||||
|
- **Auth**: Adopt orphan session cookie on first SPA load
|
||||||
|
- **Mail**: Per-account push subscriptions so multi-account notifications work (#298)
|
||||||
|
- **Mail**: Close attachment preview when clicking outside the content area
|
||||||
|
- **Mail**: Pin quick reply to the bottom for short emails
|
||||||
|
- **Mail**: Show "no body content" instead of an infinite skeleton for bodyless emails
|
||||||
|
- **Mail**: Show contact popup when clicking the sender name in the email header
|
||||||
|
- **Mail**: Prevent long addresses from overflowing email details columns (#297)
|
||||||
|
- **Mobile**: Align quick reply with the mobile bottom toolbar
|
||||||
|
- **Mobile**: Respect safe-area insets on mobile bottom bars
|
||||||
|
- **Mobile**: Pad `safe-area-inset-top`
|
||||||
|
- **UI**: Apply dark background to the email content wrapper in dark mode
|
||||||
|
- **UI**: Improve dark mode background colors in the email viewer
|
||||||
|
- **UI**: Add viewport export with `initialScale: 1`
|
||||||
|
- **UI**: Strip the Stalwart master-user `%` suffix from the displayed account
|
||||||
|
- **Plugins**: Warn and block install when the app version is below the plugin's `minAppVersion`
|
||||||
|
- **Plugins**: Register `app-top-banner` in plugin-store `SLOT_NAMES`
|
||||||
|
- **Plugins**: Carry `configSchema` + `settingsSchema` through marketplace install
|
||||||
|
- **Build**: Add `outputFileTracingExcludes` to reduce Turbopack memory tracing
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Add missing translation keys across 16 locales
|
||||||
|
|
||||||
## 1.6.6 (2026-05-15)
|
## 1.6.6 (2026-05-15)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+79
-33
@@ -10,13 +10,13 @@
|
|||||||
|
|
||||||
# Contributing to Bulwark Webmail
|
# Contributing to Bulwark Webmail
|
||||||
|
|
||||||
We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
|
We're writing the webmail we wanted in 2026 and didn't find: a JMAP-native client with an interface built this decade. It's AGPL and self-hosted, run by the people who use it rather than sold to them.
|
||||||
|
|
||||||
If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
|
If that sounds like your kind of project, we'd love the help.
|
||||||
|
|
||||||
## Join the Community
|
## Join the community
|
||||||
|
|
||||||
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
|
You don't need to be an expert to contribute. A dev environment that won't start, a bug you're not sure how to report, a translation you're stuck on: Discord is the fastest way to get unstuck and to meet the people working on this.
|
||||||
|
|
||||||
- **Get support** - real-time help with development hurdles
|
- **Get support** - real-time help with development hurdles
|
||||||
- **Share ideas** - feature suggestions, design feedback, doc improvements
|
- **Share ideas** - feature suggestions, design feedback, doc improvements
|
||||||
@@ -26,9 +26,9 @@ You don't need to be an expert to contribute. Whether you're setting up your dev
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started
|
## Getting started
|
||||||
|
|
||||||
### Development Setup
|
### Development setup
|
||||||
|
|
||||||
1. **Fork and clone** the repository:
|
1. **Fork and clone** the repository:
|
||||||
|
|
||||||
@@ -46,16 +46,22 @@ You don't need to be an expert to contribute. Whether you're setting up your dev
|
|||||||
3. **Set up environment**:
|
3. **Set up environment**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.dev.example .env.local
|
||||||
# Edit .env.local with your JMAP server URL
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This enables the built-in mock JMAP server (`DEV_MOCK_JMAP=true`), so you can
|
||||||
|
develop without a mail server. Log in with any username and password. To work
|
||||||
|
against a real server instead, copy `.env.example` and set `JMAP_SERVER_URL`.
|
||||||
|
|
||||||
4. **Start development server**:
|
4. **Start development server**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
Then open http://localhost:3000.
|
||||||
|
|
||||||
|
### Code quality
|
||||||
|
|
||||||
Before submitting a pull request, ensure your code passes all checks:
|
Before submitting a pull request, ensure your code passes all checks:
|
||||||
|
|
||||||
@@ -72,7 +78,20 @@ npm run lint:fix
|
|||||||
|
|
||||||
These checks run automatically on commit via Husky pre-commit hooks.
|
These checks run automatically on commit via Husky pre-commit hooks.
|
||||||
|
|
||||||
## Code Style Guidelines
|
### Testing
|
||||||
|
|
||||||
|
| Suite | Command | What it covers |
|
||||||
|
| ---------------- | -------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| **Unit** | `npx vitest run` | Vitest + jsdom. Tests live in `__tests__/` folders next to the code |
|
||||||
|
| **Translations** | `npm run test:translations` | Locale files checked for structural drift against English |
|
||||||
|
| **Integration** | `npm run test:integration` | Playwright against a real Stalwart server in Docker |
|
||||||
|
| **E2E smoke** | `npx playwright test` | UI smoke tests against `npm run dev` |
|
||||||
|
|
||||||
|
Run a single unit test file with `npx vitest run lib/__tests__/<name>.test.ts`, or `npx vitest` to watch.
|
||||||
|
|
||||||
|
The integration suite needs Docker and takes several minutes; it has its own setup notes and findings log in [integration/README.md](integration/README.md). New behavior that touches mail/folder synchronization or multi-account handling belongs there.
|
||||||
|
|
||||||
|
## Code style guidelines
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|
||||||
@@ -81,7 +100,7 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
|||||||
- Avoid `any` types when possible
|
- Avoid `any` types when possible
|
||||||
- Use meaningful variable and function names
|
- Use meaningful variable and function names
|
||||||
|
|
||||||
### React Components
|
### React components
|
||||||
|
|
||||||
- Use functional components with hooks
|
- Use functional components with hooks
|
||||||
- Keep components focused and single-purpose
|
- Keep components focused and single-purpose
|
||||||
@@ -97,7 +116,9 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
|||||||
|
|
||||||
## Internationalization (i18n)
|
## Internationalization (i18n)
|
||||||
|
|
||||||
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
|
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 23 additional locales (ar, ca, cs, da, de, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, sk, tr, uk, zh).
|
||||||
|
|
||||||
|
Arabic, Hebrew, and Persian render right-to-left (see `i18n/direction.ts`). Use Tailwind's **logical** utilities (`ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`) rather than physical ones (`ml-*`, `pl-*`, `left-*`) so layouts flip correctly. For popovers positioned in JS via `getBoundingClientRect()`, check `isDocumentRTL()`: inline `position: fixed` styles don't pick up logical utilities.
|
||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
||||||
@@ -126,9 +147,22 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
|||||||
router.push(`/${params.locale}/settings`);
|
router.push(`/${params.locale}/settings`);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pull Request Process
|
### Adding a new locale
|
||||||
|
|
||||||
### Before Submitting
|
Registering a new locale takes edits in four places:
|
||||||
|
|
||||||
|
1. `locales/<code>/common.json` - copy `locales/en/common.json` and translate
|
||||||
|
2. `i18n/routing.ts` - add the code to `SUPPORTED_LOCALES`
|
||||||
|
3. `i18n/request.ts` - add a `case` to the static-import switch
|
||||||
|
4. `components/ui/language-switcher.tsx` - add `{ value, label }` with the **native** language name, plus a flag in `components/ui/flag-icons.tsx`
|
||||||
|
|
||||||
|
For a right-to-left language, also add the code to `rtlLocales` in `i18n/direction.ts`.
|
||||||
|
|
||||||
|
Run `npm run test:translations` afterwards - it checks the locale files for structural drift against English.
|
||||||
|
|
||||||
|
## Pull request process
|
||||||
|
|
||||||
|
### Before submitting
|
||||||
|
|
||||||
1. **Create a feature branch**:
|
1. **Create a feature branch**:
|
||||||
|
|
||||||
@@ -138,13 +172,13 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
|||||||
|
|
||||||
2. **Make your changes** following the code style guidelines
|
2. **Make your changes** following the code style guidelines
|
||||||
|
|
||||||
3. **Test your changes** thoroughly
|
3. **Test your changes** thoroughly, and add unit tests for new logic
|
||||||
|
|
||||||
4. **Update translations** if you added user-facing text
|
4. **Update translations** if you added user-facing text
|
||||||
|
|
||||||
5. **Run all checks**:
|
5. **Run all checks**:
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck && npm run lint
|
npm run typecheck && npm run lint && npx vitest run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Submitting
|
### Submitting
|
||||||
@@ -157,7 +191,7 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
|||||||
- Screenshots for UI changes
|
- Screenshots for UI changes
|
||||||
- Reference to any related issues
|
- Reference to any related issues
|
||||||
|
|
||||||
### Commit Message Convention
|
### Commit message convention
|
||||||
|
|
||||||
Follow the conventional commits format:
|
Follow the conventional commits format:
|
||||||
|
|
||||||
@@ -177,25 +211,37 @@ fix: resolve attachment download issue
|
|||||||
docs: update README with keyboard shortcuts
|
docs: update README with keyboard shortcuts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
webmail/
|
webmail/
|
||||||
├── app/ # Next.js App Router pages
|
├── app/ # Next.js App Router
|
||||||
│ └── [locale]/ # Locale-aware routing
|
│ ├── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings)
|
||||||
├── components/ # React components
|
│ ├── (main)/admin/ # Admin dashboard
|
||||||
│ ├── email/ # Email-related components
|
│ ├── (main)/setup/ # First-launch setup wizard
|
||||||
│ ├── layout/ # Layout components
|
│ ├── (sandbox)/ # Isolated plugin sandbox routes
|
||||||
│ ├── settings/ # Settings components
|
│ └── api/ # Route handlers (auth, admin, jmap, caldav, …)
|
||||||
│ └── ui/ # Reusable UI components
|
├── components/ # React components
|
||||||
├── contexts/ # React contexts
|
│ ├── email/ # Email list, viewer, composer
|
||||||
├── hooks/ # Custom React hooks
|
│ ├── calendar/ contacts/ files/ filters/ templates/
|
||||||
├── lib/ # Utilities and libraries
|
│ ├── layout/ # Sidebar, shell, navigation
|
||||||
│ └── jmap/ # JMAP client implementation
|
│ ├── settings/ # Settings panels
|
||||||
├── locales/ # Translation files
|
│ ├── plugins/ # Plugin host UI
|
||||||
│ ├── en/ # English translations
|
│ └── ui/ # Reusable primitives
|
||||||
│ └── fr/ # French translations
|
├── contexts/ # React contexts
|
||||||
└── stores/ # Zustand state stores
|
├── hooks/ # Custom React hooks
|
||||||
|
├── i18n/ # next-intl routing, locale detection, RTL direction
|
||||||
|
├── lib/ # Utilities and libraries
|
||||||
|
│ ├── jmap/ # JMAP client implementation
|
||||||
|
│ ├── stalwart/ # Stalwart-specific admin/API helpers
|
||||||
|
│ ├── admin/ auth/ oauth/ # Config, sessions, OAuth flows
|
||||||
|
│ ├── plugin-sandbox/ # Plugin sandbox bridge and hardening
|
||||||
|
│ └── __tests__/ # Vitest unit tests
|
||||||
|
├── locales/ # Translation files, one directory per locale
|
||||||
|
├── stores/ # Zustand state stores
|
||||||
|
├── public/ # Static assets and branding
|
||||||
|
├── e2e/ # Playwright smoke tests (against `npm run dev`)
|
||||||
|
└── integration/ # Dockerized Stalwart + Playwright suite
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|||||||
+19
@@ -8,10 +8,25 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# at build time, so it cannot be changed without rebuilding.
|
# at build time, so it cannot be changed without rebuilding.
|
||||||
ARG NEXT_PUBLIC_BASE_PATH=
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||||
|
# Optional: avoid next-intl rewrite loops when served under a subpath.
|
||||||
|
# Baked in at build time.
|
||||||
|
ARG NEXT_PUBLIC_LOCALE_PREFIX=
|
||||||
|
ENV NEXT_PUBLIC_LOCALE_PREFIX=$NEXT_PUBLIC_LOCALE_PREFIX
|
||||||
|
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
||||||
|
# Accept-Language header does not match any supported locale. Baked in at
|
||||||
|
# build time because next-intl wires it into client-side routing too.
|
||||||
|
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
|
||||||
|
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||||
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
|
# 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.
|
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||||
ARG GIT_COMMIT=unknown
|
ARG GIT_COMMIT=unknown
|
||||||
ENV GIT_COMMIT=$GIT_COMMIT
|
ENV GIT_COMMIT=$GIT_COMMIT
|
||||||
|
# Build the first-party plugins (vnc/plugins/*) that ship with this fork -
|
||||||
|
# currently the audited S/MIME plugin, which the server installs into its
|
||||||
|
# plugin registry at startup (lib/admin/bundled-plugins.ts). Each plugin has
|
||||||
|
# its own package.json + lockfile, so this does its own npm ci.
|
||||||
|
# Runs BEFORE next build so a broken plugin fails the image build.
|
||||||
|
RUN node scripts/build-plugins.mjs
|
||||||
RUN npx next build --webpack
|
RUN npx next build --webpack
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
@@ -34,6 +49,10 @@ RUN apk upgrade --no-cache && \
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
# Staged first-party plugin bundles. Read by path at runtime, so Next's output
|
||||||
|
# file tracing does not carry them into .next/standalone - copy explicitly or
|
||||||
|
# the image boots with the S/MIME policy toggle on and no plugin installed.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/vnc/plugins/build ./vnc/plugins/build
|
||||||
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
+112
-97
@@ -2,134 +2,149 @@
|
|||||||
|
|
||||||
## Mail
|
## Mail
|
||||||
|
|
||||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
- Read, compose, reply, reply-all, and forward in a Tiptap rich-text editor that handles inline images, drag-and-drop embedding, and tables
|
||||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
- Gmail-style threading, expanded inline, with a conversation toggle you can switch off
|
||||||
- Unified mailbox view across all connected accounts
|
- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account.
|
||||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from.
|
||||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
- Search runs across all unified views; the per-role mailboxes add the full filter panel on top
|
||||||
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning
|
- Three mail layouts: split three-pane, focused list, or reading pane at the bottom
|
||||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies
|
||||||
- Batch operations – multi-select, archive, delete, move, tag
|
- Attachments upload, download, drag out to the file system, and preview inline. Images and PDFs render on desktop and mobile, composer attachments open on click, and `.eml` (`message/rfc822`) parts display as a nested email. There are list thumbnails, and a warning when you mention an attachment and forget it.
|
||||||
- Archive modes – direct, by year, or by month
|
- Scheduled send, plus a configurable delay before anything leaves the outbox
|
||||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
- Read receipts (MDN, RFC 8098)
|
||||||
- Star/unstar with configurable mark-as-read delay
|
- Quoted text lands in an editable island that keeps the original layout
|
||||||
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
|
- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
- Multi-select for batch archive, delete, move, and tag
|
||||||
- Plain-text composer mode and Reply-To support
|
- Archive directly, by year, or by month
|
||||||
- Configurable signature position (above or below quoted text) per identity
|
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
|
||||||
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
|
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
|
||||||
- `.eml` file import via folder right-click menu
|
- Each tag can be configured to show always, only when there are unread mails or always be hidden
|
||||||
|
- Star or unstar, with a configurable mark-as-read delay
|
||||||
|
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
||||||
|
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
||||||
|
- Plain-text composer mode and Reply-To
|
||||||
|
- The signature sits above or below the quoted text, per identity
|
||||||
|
- Override the From header in the composer. Reply to an alias on a domain you own and it auto-fills as the sender, even when no identity exists for it.
|
||||||
|
- Import `.eml` files from the folder right-click menu
|
||||||
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||||
- Folder management with icon picker, subfolders, and sidebar counts
|
- Folders take an icon, nest, and show counts in the sidebar
|
||||||
- Print directly from the viewer
|
- Print from the viewer
|
||||||
- Browser history sync for back/forward navigation
|
- Browser back and forward move through mail history
|
||||||
|
|
||||||
## Calendar
|
## Calendar
|
||||||
|
|
||||||
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar
|
||||||
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes.
|
||||||
- Recurring events with scoped edit/delete (this / this and following / all)
|
- Recurring events edit and delete by scope: this occurrence, this and following, or all
|
||||||
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment
|
||||||
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message
|
||||||
- iCalendar import with preview, bulk create, and UID deduplication
|
- iCalendar import previews first, then bulk-creates, deduplicating on UID
|
||||||
- iCal / webcal subscriptions with editing and batch import
|
- iCal / webcal subscriptions, editable, with batch import
|
||||||
- Auto-generated birthday calendar from contacts
|
- A birthday calendar generated from your contacts
|
||||||
- Virtual locations (video conference URLs) as first-class event fields
|
- Virtual locations (video-conference URLs) are first-class event fields
|
||||||
- Task management with due dates, priority, and completion status
|
- Tasks with due dates, priority, and completion status
|
||||||
- Shared calendars with CalDAV discovery and multi-account home resolution
|
- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer
|
||||||
- Week numbers, event hover preview, notifications with sound picker
|
- Week numbers, hover preview, notifications with a sound picker
|
||||||
- Real-time sync via JMAP push
|
- JMAP push keeps everything in sync
|
||||||
|
|
||||||
## Contacts
|
## Contacts
|
||||||
|
|
||||||
- JMAP sync (RFC 9553 / 9610) with local fallback
|
- JMAP sync (RFC 9553 / 9610), falling back to local storage
|
||||||
- Multiple address books with drag-and-drop between books
|
- Several address books, with drag-and-drop between them
|
||||||
- Contact groups with member management
|
- Groups with member management
|
||||||
- vCard import/export (RFC 6350) with duplicate detection
|
- vCard import/export (RFC 6350) that flags duplicates
|
||||||
- Trusted senders stored in a dedicated JMAP address book
|
- Trusted senders live in their own JMAP address book
|
||||||
- Autocomplete in the composer (To / Cc / Bcc)
|
- Autocomplete on To, Cc, and Bcc
|
||||||
|
|
||||||
## Filters & Templates
|
## Filters & templates
|
||||||
|
|
||||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
- Server-side filters as JMAP Sieve Scripts (RFC 9661)
|
||||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
- A visual rule builder: conditions on From, To, Subject, Size, Body, Attachment and more, each matching multiple values, with actions to move, forward, star, or discard
|
||||||
- Preserves rules authored in other clients
|
- Rules written in other clients survive the round-trip
|
||||||
- Raw Sieve editor with syntax validation
|
- Raw Sieve editor with syntax validation
|
||||||
- Vacation responder with date range scheduling
|
- A vacation responder you can schedule to a date range
|
||||||
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- JMAP FileNode browser (Stalwart native cloud storage)
|
- Browse Stalwart's native JMAP FileNode storage as a real folder tree. Legacy flat-named files migrate into nested `FileNode` folders on first load.
|
||||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
- Streamed WebDAV PUT upload, whole folders included, with progress
|
||||||
- Dynamic upload limits based on server configuration
|
- Upload limits follow the server's own configuration
|
||||||
- Grid and list views with sorting by name, size, or date
|
- Grid or list, sorted by name, size, or date
|
||||||
- Previews for images, text, audio, and video
|
- Preview images, text, audio, and video
|
||||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
- Cut, copy, paste, duplicate; favorites; recent files
|
||||||
|
- JMAP sharing (RFC 9670) for files and folders. Pick a user or group from the principal picker and grant read, read/write, or manager. Shared items get an indicator, and anything other principals share with you appears under "Shared with me".
|
||||||
|
|
||||||
## Security & Privacy
|
## Security & privacy
|
||||||
|
|
||||||
- External content blocked by default, with a trusted senders list
|
- External content stays blocked until you say otherwise, and trusted senders are remembered
|
||||||
- HTML sanitization via DOMPurify
|
- HTML sanitized through DOMPurify
|
||||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account.
|
||||||
- SPF / DKIM / DMARC status indicators
|
- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop the "via" badge on spoofed mail
|
||||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
- OAuth2 / OIDC with PKCE against Keycloak, Authentik, or the built-in provider, plus OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||||
- TOTP two-factor authentication
|
- TOTP two-factor authentication
|
||||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
- Password and 2FA management through the Stalwart admin API
|
||||||
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie
|
||||||
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention
|
||||||
- Plugin hardening with dangerous-pattern detection and admin approval
|
- Plugins are scanned for dangerous patterns and need admin approval
|
||||||
- Newsletter unsubscribe (RFC 2369)
|
- Newsletter unsubscribe (RFC 2369)
|
||||||
|
|
||||||
## Interface
|
## Interface
|
||||||
|
|
||||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
- Split three-pane, focused list, or bottom reading pane, columns resizable
|
||||||
- Dark and light themes with intelligent email color transformation
|
- Dark and light themes. Email colors are remapped by luminance, so a mail hard-coded to dark-on-white stays readable on a dark background.
|
||||||
- Responsive desktop, tablet, and mobile layouts
|
- Bundled themes such as Aurora Glass and Elastic. Each theme card renders as a miniature mailbox built from that theme's own colors, with chips for the light and dark variants.
|
||||||
|
- Layouts for desktop, tablet, and mobile
|
||||||
- Full keyboard navigation
|
- Full keyboard navigation
|
||||||
- Drag-and-drop email organization and tag assignment
|
- Drag and drop to organize mail and assign tags
|
||||||
- Interactive guided tour for new users
|
- A guided tour for first-time users
|
||||||
- Right-click context menus, toast notifications with undo
|
- Right-click menus, and toasts that offer an undo
|
||||||
- Customizable toolbar position, favicon, and login branding
|
- Toolbar position, favicon, and login branding are configurable
|
||||||
- Pinnable sidebar apps with drag-and-drop reordering
|
- Sidebar apps pin and reorder by drag
|
||||||
- Encrypted settings sync across devices
|
- Settings sync between devices, encrypted
|
||||||
- Storage quota display
|
- Storage quota display
|
||||||
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
|
- WCAG AA contrast, reduced-motion support, focus traps, and screen-reader live regions
|
||||||
|
|
||||||
## Internationalization
|
## Internationalization
|
||||||
|
|
||||||
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
24 languages: Català · Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Slovenčina · Türkçe · Русский · Українська · עברית · العربية · فارسی · 한국어 · 日本語 · 简体中文
|
||||||
|
|
||||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
- Arabic, Hebrew, and Persian render right-to-left; document direction and logical layout flip automatically
|
||||||
|
- The browser's `Accept-Language` picks the first language, and the choice persists per user
|
||||||
|
- `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix
|
||||||
|
|
||||||
## Identity & Multi-Account
|
## Identity & multi-account
|
||||||
|
|
||||||
- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1)
|
- Run several accounts at once and switch instantly, each keeping its own session. The 5-account cap lifts on HTTP/2 servers; on HTTP/1.1, browser connection pooling still sets the limit.
|
||||||
- Account switcher with connection status and default account selection
|
- An account switcher showing connection status, and a default account
|
||||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list
|
||||||
- Configurable signature position (above or below quoted text)
|
- Signature above or below the quoted text
|
||||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context
|
||||||
- Shared folders across accounts
|
- Shared folders across accounts
|
||||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
- Shared and group (delegated) accounts put their folders next to your own, and "Include group inboxes" merges them into the Unified Mailbox. You can open, mark read, flag as spam or not-spam, move, delete, and archive their messages from there, and folder unread counts stay in step.
|
||||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
- Several JMAP servers per deployment, optionally auto-picked by email domain
|
||||||
|
- Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it
|
||||||
|
|
||||||
## Admin & Extensibility
|
## Admin & extensibility
|
||||||
|
|
||||||
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
- A setup wizard runs on first launch and walks through JMAP servers, OAuth/OIDC, the session secret, logging, branding (uploads included), and the admin password. It writes to the admin config dir, so `.env.local` stays untouched.
|
||||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
- The Stalwart admin dashboard, its policy sections collapsed into one tabbed page
|
||||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
- Admin policy gates for the Unified Mailbox: turn All mail / Unread / Starred on or off org-wide, and gate cross-account capability separately (off by default, auto-enabled on upgrade for instances already using it). A gated view still respects the user's own toggle.
|
||||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement
|
- Admin storage splits in two. `ADMIN_CONFIG_DIR` is operator-authored and can be mounted read-only once setup finishes; `ADMIN_STATE_DIR` holds the runtime audit log and login timestamps.
|
||||||
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts
|
||||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
- An admin toggle controls search-engine indexing (`robots.txt` / `noindex`)
|
||||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
- Plugin system: a schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (sandboxed plugins localize through manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
|
||||||
- Bundled plugins including Jitsi Meet calendar integration
|
- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins`
|
||||||
|
- Themes upload as ZIP bundles, and admins can enforce one
|
||||||
|
- An extension marketplace browses and installs plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`). Installing and uninstalling stay in the admin dashboard.
|
||||||
|
- Bundled plugins, including Jitsi Meet for the calendar
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
|
- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain
|
||||||
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
- Update checks run on their own, log new releases server-side, and raise a notice that can't be dismissed
|
||||||
- Structured logging (`text` or `json`) with category-based levels
|
- Structured logging (`text` or `json`) with per-category levels
|
||||||
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only
|
- Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles.
|
||||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
- Docker images on GHCR, for release (`main`) and development (`dev`)
|
||||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
|
||||||
- Demo mode with fixture data – no mail server required
|
- Demo mode runs on fixture data, no mail server required
|
||||||
|
|||||||
@@ -8,26 +8,19 @@
|
|||||||
|
|
||||||
# Bulwark Webmail
|
# Bulwark Webmail
|
||||||
|
|
||||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
A self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
[](https://grafana.external.bulwarkmail.org/)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installer
|
## Installer
|
||||||
|
|
||||||
New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
Since **1.6.4**, a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
||||||
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
|
|
||||||
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
|
|
||||||
</picture>
|
|
||||||
|
|
||||||
Point a browser at the running container and the wizard guides you through:
|
Point a browser at the running container and the wizard guides you through:
|
||||||
|
|
||||||
@@ -72,27 +65,27 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
|
|||||||
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><sub><b>Light mode</b> – full theme support with intelligent color transformation for HTML emails.</sub></td>
|
<td><sub><b>Light mode</b> – full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable.</sub></td>
|
||||||
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## Overview
|
## What Bulwark includes
|
||||||
|
|
||||||
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:
|
Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting:
|
||||||
|
|
||||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
- **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
|
||||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||||
- **Contacts** – multiple address books, groups, vCard import/export
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||||
|
|
||||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
They share one login, one settings store, and one admin dashboard. SSO, 2FA, multi-account, 24 languages, PWA install, themes, and plugins apply across all four.
|
||||||
|
|
||||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick start
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -106,9 +99,9 @@ Or with Docker Compose:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
On first launch, open `http://localhost:3000` – the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration).
|
On first launch, open `http://localhost:3000` and the setup wizard takes over. Installs that already define `JMAP_SERVER_URL` skip it and keep the env-managed flow under [Configuration](#configuration).
|
||||||
|
|
||||||
### From Source
|
### From source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/bulwarkmail/webmail.git
|
git clone https://github.com/bulwarkmail/webmail.git
|
||||||
@@ -121,16 +114,20 @@ npm run build && npm start
|
|||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Dev server with a mock JMAP server
|
cp .env.dev.example .env.local # Built-in mock JMAP server, no mail server needed
|
||||||
|
|
||||||
|
npm run dev # Dev server
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run lint
|
npm run lint
|
||||||
|
npx vitest run # Unit tests
|
||||||
|
npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI.
|
Most deployments are configured through the setup wizard on first launch, then the admin dashboard; those values live in the admin config directory rather than `.env.local`. Environment variables still work, and they suit read-only or immutable infrastructure better. An environment variable always wins over the admin-managed value, so setting `JMAP_SERVER_URL` hides that field from the wizard and locks it in the admin UI.
|
||||||
|
|
||||||
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
Nearly all variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. The exceptions are the `NEXT_PUBLIC_*` ones noted below, which Next.js bakes in at build time. Edit `.env.local`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Optional – overrides whatever the wizard writes
|
# Optional – overrides whatever the wizard writes
|
||||||
@@ -153,13 +150,28 @@ PORT=3000
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
OAUTH_ENABLED=true
|
OAUTH_ENABLED=true
|
||||||
|
OAUTH_ONLY=true # hide the username/password form entirely
|
||||||
OAUTH_CLIENT_ID=webmail
|
OAUTH_CLIENT_ID=webmail
|
||||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||||
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||||
OAUTH_ISSUER_URL= # optional, for external IdPs
|
OAUTH_ISSUER_URL= # optional, for external IdPs
|
||||||
|
OAUTH_AUTHORIZE_URL= # override only the user-facing authorize endpoint
|
||||||
|
OAUTH_ALLOW_PRIVATE_ENDPOINTS= # allow discovery to resolve to RFC-1918 addresses
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. `OAUTH_ALLOW_PRIVATE_ENDPOINTS` is off by default as an SSRF guard. Enable it only for split-DNS deployments where the issuer's public hostname resolves to an internal IP.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Anonymous telemetry</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
BULWARK_TELEMETRY=on # opt-in; off by default
|
||||||
|
TELEMETRY_DATA_DIR=./data/telemetry # instance id and consent; mount a volume
|
||||||
|
```
|
||||||
|
|
||||||
|
Off unless you turn it on, in the admin UI, the installer, or here. Heartbeats carry version, platform, bucketed account counts, and feature toggles. No email addresses, hostnames, or IPs. Setting the variable (to either value) locks the choice and disables the admin toggle.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -211,6 +223,12 @@ LOGIN_COMPANY_NAME=My Company
|
|||||||
LOGIN_WEBSITE_URL=https://example.com
|
LOGIN_WEBSITE_URL=https://example.com
|
||||||
LOGIN_IMPRINT_URL=https://example.com/imprint
|
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
|
||||||
|
# Per-domain overrides (optional). When the webmail is served on multiple
|
||||||
|
# hostnames, each host can override any subset of the branding fields above.
|
||||||
|
# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to
|
||||||
|
# match any subdomain. Unset fields fall back to the global values.
|
||||||
|
DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -251,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Default UI locale</summary>
|
||||||
|
|
||||||
|
The UI language follows each visitor's `Accept-Language` header and their stored preference. `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback used when neither matches a supported locale (default `en`):
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_DEFAULT_LOCALE=de
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported: `ar`, `ca`, `cs`, `da`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hu`, `it`, `ja`, `ko`, `lv`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `tr`, `uk`, `zh`. An unsupported value falls back to `en`.
|
||||||
|
|
||||||
|
Like `NEXT_PUBLIC_BASE_PATH`, this is read at **build time**. To use it with the published Docker image, build your own:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --build-arg NEXT_PUBLIC_DEFAULT_LOCALE=de -t bulwark-webmail .
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Subpath / reverse proxy mount</summary>
|
<summary>Subpath / reverse proxy mount</summary>
|
||||||
|
|
||||||
@@ -267,37 +304,46 @@ Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** b
|
|||||||
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
|
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Then point your reverse proxy at the container without stripping the prefix. The app expects requests under `/webmail/...` and serves every route (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, and so on) accordingly.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| ------------- | ----------------------- |
|
| -------------------- | ----------------------- |
|
||||||
| `j` / `k` | Navigate between emails |
|
| `j` `↓` / `k` `↑` | Navigate between emails |
|
||||||
| `Enter` / `o` | Open email |
|
| `Enter` / `o` | Open email |
|
||||||
| `Esc` | Close / deselect |
|
| `Esc` | Close / deselect |
|
||||||
| `c` | Compose |
|
| `x` | Expand / collapse thread |
|
||||||
| `r` / `R` | Reply / Reply all |
|
| `c` | Compose |
|
||||||
| `f` | Forward |
|
| `r` / `R` `a` | Reply / Reply all |
|
||||||
| `s` | Star |
|
| `f` | Forward |
|
||||||
| `e` | Archive |
|
| `s` | Star |
|
||||||
| `#` | Delete |
|
| `e` | Archive |
|
||||||
| `/` | Search |
|
| `#` / `Del` | Delete |
|
||||||
| `?` | Show all shortcuts |
|
| `u` / `Shift`+`I` | Mark unread / read |
|
||||||
|
| `!` | Toggle spam |
|
||||||
|
| `Ctrl`+`A` | Select all |
|
||||||
|
| `Shift`+`G` | Refresh |
|
||||||
|
| `/` | Search |
|
||||||
|
| `?` | Show all shortcuts |
|
||||||
|
|
||||||
## Tech Stack
|
In the composer: `Ctrl/Cmd`+`Enter` sends, `Ctrl/Cmd`+`Shift`+`Enter` opens scheduled send, and `t` opens the template picker.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
| ------------- | ------------------------------------------------- |
|
| ------------- | ------------------------------------------------- |
|
||||||
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router |
|
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router, React 19 |
|
||||||
| **Language** | TypeScript |
|
| **Language** | TypeScript |
|
||||||
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
|
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
|
||||||
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
|
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
|
||||||
| **Protocol** | Custom JMAP client (RFC 8620) |
|
| **Protocol** | Custom JMAP client (RFC 8620) |
|
||||||
|
| **Editor** | [Tiptap](https://tiptap.dev/) |
|
||||||
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
|
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
|
||||||
| **Icons** | [Lucide React](https://lucide.dev/) |
|
| **Icons** | [Lucide React](https://lucide.dev/) |
|
||||||
|
| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
|
||||||
|
|
||||||
## Why Stalwart?
|
## Why Stalwart?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# VNCmail+ — setup & deploy runbook
|
||||||
|
|
||||||
|
VNCmail+ is VNC's fork of [Bulwark](https://github.com/bulwarkmail/webmail), a
|
||||||
|
Next.js (App Router) JMAP webmail client for **Stalwart**. Stalwart is the source
|
||||||
|
of truth; VNCmail+ is the UI. It deploys as a **container on Kubernetes
|
||||||
|
(microk8s)** at `vncmail.sandbox.vnc.de` — see **[deploy/k8s/](deploy/k8s/README.md)**.
|
||||||
|
|
||||||
|
> **License:** AGPL-3.0. Serving a modified VNCmail+ to users over the network
|
||||||
|
> obligates VNC to offer those users the corresponding source. Keeping this fork
|
||||||
|
> public (with a "Source" link in the imprint/UI) satisfies that. Loop in legal
|
||||||
|
> before a public/customer-facing launch if a closed fork is ever desired.
|
||||||
|
|
||||||
|
## Architecture — why a container, not Vercel
|
||||||
|
|
||||||
|
- Bulwark is a **stateful, long-lived server**: it persists settings-sync, admin
|
||||||
|
config/state, and telemetry to a **local data directory** (`/app/data/*`).
|
||||||
|
- **Vercel serverless was tried and dropped** — its filesystem is read-only
|
||||||
|
except `/tmp`, so Bulwark's `mkdir ./data` crashes (`ENOENT /var/task/data`).
|
||||||
|
You cannot point its data dirs at a remote host either (they're POSIX paths,
|
||||||
|
not URLs). Bulwark's native model is a container + persistent volumes.
|
||||||
|
- So VNCmail+ runs as a Docker image with **4 persistent volumes**, exactly
|
||||||
|
like the existing `bulwark.sandbox.vnc.de`.
|
||||||
|
- JMAP calls go through **server-side `/api/*` routes** (`proxy.ts`) → server-to-
|
||||||
|
server to Stalwart, **no browser CORS**. Config is **runtime-read**.
|
||||||
|
|
||||||
|
## Branches (dev-first)
|
||||||
|
|
||||||
|
| Branch | Role |
|
||||||
|
|--------|------|
|
||||||
|
| `main` | **Production.** Only updated by `git merge --ff-only dev`, then an explicit manual promote in CI. No prod environment exists yet — see "CI/CD" below. |
|
||||||
|
| `dev` | Integration + QA — default working branch. Every push auto-builds and auto-deploys to the sandbox (`vncmail.sandbox.vnc.de`). |
|
||||||
|
| `vnc/*`| Feature branches for UI work (branch off `dev`, MR into `dev` — required, gated by CI). |
|
||||||
|
|
||||||
|
All VNC customization lives under `vnc/` (see `vnc/VNC-CHANGES.md`).
|
||||||
|
|
||||||
|
## CI/CD — GitLab (canonical) + ArgoCD GitOps, Vercel-style dev→prod
|
||||||
|
|
||||||
|
Multiple developers work on this repo now. `.gitlab-ci.yml` on
|
||||||
|
[gitlab.vnc.biz](https://gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus)
|
||||||
|
(the canonical remote — GitHub `origin` is a passive mirror, not where CI or
|
||||||
|
deploys happen) builds images and bumps a tag in git; **ArgoCD does the
|
||||||
|
actual deploying** — already installed and idle on the `dev-k8s-1/2/3`
|
||||||
|
cluster, discovered when standing this up. GitLab CI needs zero cluster
|
||||||
|
credentials as a result.
|
||||||
|
|
||||||
|
Two real clusters, confirmed by direct inspection:
|
||||||
|
|
||||||
|
| Cluster | Role | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `dev-k8s-1/2/3` | dev/sandbox | ~hours old when set up here. Traefik, metallb, cert-manager (`letsencrypt-staging` issuer only), **ArgoCD already running**. |
|
||||||
|
| `node1/node2/node3` | prod (HA) | Older, rook-ceph+traefik+metallb+cert-manager, but **zero apps and zero ClusterIssuers** — genuinely a clean slate. |
|
||||||
|
|
||||||
|
Neither cluster had a `vncmail` namespace, `vnc-ca` namespace, or `bulwark`
|
||||||
|
ingress — the "live sandbox at vncmail.sandbox.vnc.de" referenced earlier in
|
||||||
|
this doc's history was aspirational (manifests + docs existed, nothing was
|
||||||
|
ever actually applied). The ingress manifests also assumed nginx (`class:
|
||||||
|
public`, an nginx body-size annotation) — fixed to Traefik's real
|
||||||
|
`ingressClassName: traefik` (Traefik has no default body-size cap, so no
|
||||||
|
replacement annotation is needed).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. **MR into `dev`** → `verify` stage (typecheck/lint/unit test/build).
|
||||||
|
Required check — no push, no deploy.
|
||||||
|
2. **Merge to `dev`** → `build` pushes one image,
|
||||||
|
`registry.gitlab.vnc.biz/.../vncmail-plus:sha-<sha>`, then `bump-dev`
|
||||||
|
commits that tag into `deploy/k8s/overlays/dev/image-tag/kustomization.yaml`
|
||||||
|
(`[skip ci]`). ArgoCD's `vncmail-dev` Application picks up the git change.
|
||||||
|
3. **Merge to `main`** (fast-forward only, see below) → `bump-prod` points
|
||||||
|
`overlays/prod/image-tag/` at that same tag — **no rebuild**. The actual
|
||||||
|
promotion gate is a **human clicking Sync** on the `vncmail-prod` ArgoCD
|
||||||
|
Application, which is permanently manual-sync (never automated) — that's
|
||||||
|
the Vercel-style "Promote to Production" button, just living in ArgoCD's
|
||||||
|
UI instead of GitLab's.
|
||||||
|
|
||||||
|
### What's left to wire up (one-time, human steps)
|
||||||
|
|
||||||
|
1. **Add the ArgoCD deploy key to GitLab** — Project → Settings → Repository
|
||||||
|
→ Deploy keys → add (read-only is enough):
|
||||||
|
```
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOURjX/Y9zfB785DyLEF1GUq4HhWujrqeXag8oxdMciq argocd@dev-k8s (vncmail-plus read-only)
|
||||||
|
```
|
||||||
|
Until this is added, `vncmail-dev`'s ArgoCD Application (already created,
|
||||||
|
`kubectl -n argocd get application vncmail-dev`) shows a benign
|
||||||
|
`ComparisonError` (SSH handshake failing) — expected, not a bug.
|
||||||
|
2. **Let CI push tag-bumps back to this repo** — either enable "this project
|
||||||
|
can be accessed by CI/CD job tokens from other projects" → actually
|
||||||
|
simpler: Settings → CI/CD → Job token permissions → allow this project's
|
||||||
|
own job token to push to itself, OR create a Project Access Token
|
||||||
|
(`write_repository` scope) and add it as a masked CI/CD variable
|
||||||
|
`GITLAB_PUSH_TOKEN` (the pipeline tries that first, falls back to
|
||||||
|
`CI_JOB_TOKEN`).
|
||||||
|
3. **One-time namespace bootstrap** (CI/ArgoCD deliberately never manage
|
||||||
|
secret contents — see `deploy/k8s/README.md` §3):
|
||||||
|
```bash
|
||||||
|
# against dev-k8s (ArgoCD's CreateNamespace=true will make `vncmail` on
|
||||||
|
# first sync, or create it yourself first — either order works)
|
||||||
|
kubectl create secret docker-registry ghcr-pull -n vncmail ... # or make the GHCR package public
|
||||||
|
cp deploy/k8s/overlays/dev/secret.example.yaml secret.yaml # edit SESSION_SECRET
|
||||||
|
kubectl apply -f secret.yaml
|
||||||
|
```
|
||||||
|
4. **First sync** — ArgoCD UI at `https://argo.devcluster.vnc.de`
|
||||||
|
(username `admin`, password: `kubectl -n argocd get secret
|
||||||
|
argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d`
|
||||||
|
— rotate it after logging in once) → `vncmail-dev` → Sync. Once that's
|
||||||
|
clean, flip `deploy/argocd/vncmail-dev-app.yaml`'s commented-out
|
||||||
|
`automated:` block on and re-apply, so dev auto-syncs on every push from
|
||||||
|
then on.
|
||||||
|
5. **Production** (later, deliberately not wired yet): decide a real
|
||||||
|
hostname, stand up prod Stalwart, register `node1-3` as an ArgoCD-managed
|
||||||
|
cluster, apply `deploy/argocd/vncmail-prod-app.yaml`, fill in real
|
||||||
|
`overlays/prod` values, create a real ClusterIssuer on `node1-3` (there
|
||||||
|
isn't one today), then click Sync once — deliberately not before.
|
||||||
|
|
||||||
|
Historical note: the old `-dev`/`-beta` GHCR image-name split
|
||||||
|
(`.github/workflows/docker-publish.yml`) is retired by this — one image name
|
||||||
|
now, environment lives only in the tag.
|
||||||
|
|
||||||
|
## Deploy (Kubernetes / microk8s)
|
||||||
|
|
||||||
|
Full runbook: **[deploy/k8s/README.md](deploy/k8s/README.md)**. In short:
|
||||||
|
|
||||||
|
1. CI (above) builds and pushes the image, one name/many tags, to GitLab's
|
||||||
|
registry.
|
||||||
|
2. `kubectl apply -k deploy/k8s/overlays/dev` (or `overlays/prod`, once real)
|
||||||
|
— base manifests (namespace, 4 PVCs, deployment, service, ingress) live in
|
||||||
|
`deploy/k8s/base/`, environment differences (namespace, hostname, replica
|
||||||
|
count) are overlay patches.
|
||||||
|
3. DNS + a `secret.yaml` (from the overlay's `secret.example.yaml`, gitignored,
|
||||||
|
created once by hand — CI never manages secret contents) + an image-pull
|
||||||
|
secret are the remaining manual, human, one-time steps per environment.
|
||||||
|
|
||||||
|
Runs alongside the existing `bulwark.sandbox.vnc.de`. Match your cluster's
|
||||||
|
StorageClass / IngressClass / cert issuer to bulwark's (see the runbook).
|
||||||
|
|
||||||
|
## Deploy workflow (dev-first — ALWAYS)
|
||||||
|
|
||||||
|
Same flow as every other VNC/SRC repo, now enforced structurally by CI rather
|
||||||
|
than by convention:
|
||||||
|
|
||||||
|
1. Work on `dev` (or `vnc/*` → MR into `dev`, CI-gated). Merge → auto-builds
|
||||||
|
and auto-deploys to `vncmail.sandbox.vnc.de`. QA there.
|
||||||
|
2. **Promote to production only on explicit go-live** — merge `dev` → `main`:
|
||||||
|
```bash
|
||||||
|
git log dev..main # MUST be empty — main must have nothing dev lacks (else prod would revert)
|
||||||
|
git checkout main && git merge --ff-only dev
|
||||||
|
git push gitlab main # never GitHub — opens the manual `promote` job, does not run it
|
||||||
|
git checkout dev
|
||||||
|
```
|
||||||
|
Then click `promote` in the GitLab pipeline UI (protected `production`
|
||||||
|
environment — requires the right role) once prod actually exists (see
|
||||||
|
"CI/CD" above). Never push straight to `main`. Never let a dev→main merge
|
||||||
|
silently revert prod.
|
||||||
|
|
||||||
|
## Syncing upstream (Bulwark releases)
|
||||||
|
|
||||||
|
Bring upstream into `dev` (NOT main), integrate + QA on the dev image, then promote as above:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git checkout dev && git merge upstream/main # resolve conflicts via vnc/VNC-CHANGES.md; QA on preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Basic auth via Stalwart is the default — users sign in with their
|
||||||
|
`@sandbox.vnc.de` address + password; VNCmail+ authenticates them over JMAP. No
|
||||||
|
extra config. (SSO via vncdirectory/OIDC is a later option — see
|
||||||
|
`vnc/vercel.env.template`.)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
// Catch-all that anchors unmatched URLs into the (main) route group so
|
||||||
|
// Next renders app/(main)/not-found.tsx (wrapped by (main)/layout.tsx)
|
||||||
|
// instead of the built-in __next_builtin__not-found page. Without this,
|
||||||
|
// route groups can't pick a root layout for URLs that match nothing, so
|
||||||
|
// 404s render bare.
|
||||||
|
export default function CatchAll() {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
|
||||||
import { Loader2, AlertCircle } from "lucide-react";
|
import { Loader2, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
@@ -32,6 +32,42 @@ function OAuthCallbackInner() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step-up re-auth for device pairing: the QR generator sent the user here
|
||||||
|
// via prompt=login. Don't create a login session — just confirm the fresh
|
||||||
|
// auth (sets the short-lived pairing proof cookie) and bounce back to the
|
||||||
|
// Security settings, where the QR generation auto-resumes.
|
||||||
|
let pairReauthResume = false;
|
||||||
|
try {
|
||||||
|
pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1";
|
||||||
|
} catch { /* sessionStorage unavailable */ }
|
||||||
|
if (pairReauthResume && state) {
|
||||||
|
try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ }
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/auth/reauth/sso/complete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ code, state }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("pair_reauth_done", "1");
|
||||||
|
// Land back on the Security tab (readPersistedTab reads this key).
|
||||||
|
sessionStorage.setItem("settings-deep-link-tab", "security");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
|
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
|
||||||
|
} catch {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
|
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
@@ -69,7 +105,7 @@ function OAuthCallbackInner() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
} else {
|
} else {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
}
|
||||||
@@ -78,7 +114,69 @@ function OAuthCallbackInner() {
|
|||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
});
|
});
|
||||||
} else if (state) {
|
} else if (state) {
|
||||||
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
// Server-side SSO flow - state was stored in encrypted httpOnly cookie.
|
||||||
|
// Branch on mobile handoff first: the login page left a marker in
|
||||||
|
// sessionStorage if it kicked this OAuth dance off for the mobile app.
|
||||||
|
let mobileRedirectUri: string | null = null;
|
||||||
|
let mobileState: string | null = null;
|
||||||
|
try {
|
||||||
|
mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri");
|
||||||
|
mobileState = sessionStorage.getItem("mobile_state");
|
||||||
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
|
|
||||||
|
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
|
||||||
|
// Drive /api/auth/sso/complete directly so we can read the tokens
|
||||||
|
// out of the response - loginWithServerSso would consume them and
|
||||||
|
// wire up the webmail auth store, which isn't useful here. The
|
||||||
|
// server's mobile-flow branch (keyed on the pending cookie) skips
|
||||||
|
// the refresh-token cookie write for the same reason.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/auth/sso/complete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ code, state }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const serverUrl = data.server_url as string | undefined;
|
||||||
|
const accessToken = data.access_token as string | undefined;
|
||||||
|
const tokenEndpoint = data.token_endpoint as string | undefined;
|
||||||
|
const clientId = data.client_id as string | undefined;
|
||||||
|
if (!serverUrl || !accessToken || !tokenEndpoint || !clientId) {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = new URLSearchParams({
|
||||||
|
flow: "oauth",
|
||||||
|
server_url: serverUrl,
|
||||||
|
access_token: accessToken,
|
||||||
|
token_endpoint: tokenEndpoint,
|
||||||
|
client_id: clientId,
|
||||||
|
state: mobileState ?? "",
|
||||||
|
});
|
||||||
|
if (typeof data.refresh_token === "string") {
|
||||||
|
fragment.set("refresh_token", data.refresh_token);
|
||||||
|
}
|
||||||
|
if (typeof data.expires_in === "number") {
|
||||||
|
fragment.set("expires_in", String(data.expires_in));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem("mobile_redirect_uri");
|
||||||
|
sessionStorage.removeItem("mobile_state");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||||
|
} catch {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
loginWithServerSso(code, state)
|
loginWithServerSso(code, state)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
@@ -91,7 +189,7 @@ function OAuthCallbackInner() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
} else {
|
} else {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
}
|
||||||
@@ -119,7 +217,7 @@ function OAuthCallbackInner() {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
|
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
|
||||||
>
|
>
|
||||||
{t("oauth_error.back_to_login")}
|
{t("oauth_error.back_to_login")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -16,8 +16,9 @@ import { useEmailStore } from "@/stores/email-store";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
||||||
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||||
@@ -32,6 +33,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
|
|||||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||||
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
|
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
@@ -44,6 +46,8 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
|||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
|
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
@@ -57,6 +61,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
|||||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
|
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||||
@@ -74,6 +79,13 @@ export default function CalendarPage() {
|
|||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
|
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
|
||||||
|
// collapses into a burger-toggled overlay instead of taking inline space.
|
||||||
|
const isNarrow = !isDesktop;
|
||||||
|
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||||
|
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
|
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -85,8 +97,15 @@ export default function CalendarPage() {
|
|||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||||
removeCalendar, clearCalendarEvents,
|
removeCalendar, clearCalendarEvents,
|
||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
|
newEventPrefill, setNewEventPrefill,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||||
|
const calendarTasksEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarTasksEnabled'));
|
||||||
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks: userTasksEnabled, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
|
const enableCalendarTasks = userTasksEnabled && calendarTasksEnabled;
|
||||||
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
|
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
@@ -165,10 +184,13 @@ export default function CalendarPage() {
|
|||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
|
} else if (client && !calendarEnabled) {
|
||||||
|
// Calendar disabled by admin policy - send the user back to mail.
|
||||||
|
router.push("/");
|
||||||
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -254,12 +276,17 @@ export default function CalendarPage() {
|
|||||||
return subscribeToPendingWebcal(openPendingWebcal);
|
return subscribeToPendingWebcal(openPendingWebcal);
|
||||||
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
|
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
|
||||||
|
|
||||||
|
// Single-account fetch path. The Pro shell aggregates calendars from
|
||||||
|
// every connected account via [[useProMultiAccountCalendars]] below, so
|
||||||
|
// skip this fetch there to avoid clobbering the merged list with the
|
||||||
|
// active client's calendars only.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEmbedded) return;
|
||||||
if (client && !hasFetched.current) {
|
if (client && !hasFetched.current) {
|
||||||
hasFetched.current = true;
|
hasFetched.current = true;
|
||||||
fetchCalendars(client);
|
fetchCalendars(client);
|
||||||
}
|
}
|
||||||
}, [client, fetchCalendars]);
|
}, [client, fetchCalendars, isEmbedded]);
|
||||||
|
|
||||||
// Auto-refresh iCal subscriptions
|
// Auto-refresh iCal subscriptions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -328,10 +355,21 @@ export default function CalendarPage() {
|
|||||||
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEmbedded) return;
|
||||||
if (client && calendars.length > 0 && dateRange) {
|
if (client && calendars.length > 0 && dateRange) {
|
||||||
fetchEvents(client, dateRange.start, dateRange.end);
|
fetchEvents(client, dateRange.start, dateRange.end);
|
||||||
}
|
}
|
||||||
}, [client, calendars.length, dateRange, fetchEvents]);
|
}, [client, calendars.length, dateRange, fetchEvents, isEmbedded]);
|
||||||
|
|
||||||
|
// Pro shell only: aggregate calendars and events from every connected
|
||||||
|
// account so the sidebar lists them all (and the views render their
|
||||||
|
// events together). The hook is a no-op outside the embedded shell.
|
||||||
|
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars(
|
||||||
|
isEmbedded ? dateRange?.start ?? null : null,
|
||||||
|
isEmbedded ? dateRange?.end ?? null : null,
|
||||||
|
);
|
||||||
|
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||||
|
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||||
|
|
||||||
const navigatePrev = useCallback(() => {
|
const navigatePrev = useCallback(() => {
|
||||||
let next: Date;
|
let next: Date;
|
||||||
@@ -403,6 +441,8 @@ export default function CalendarPage() {
|
|||||||
setMobileReturnToMonth(true);
|
setMobileReturnToMonth(true);
|
||||||
setViewMode("day");
|
setViewMode("day");
|
||||||
}
|
}
|
||||||
|
// Close the narrow-pane sidebar overlay after the user picks a date.
|
||||||
|
setNarrowSidebarOpen(false);
|
||||||
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
||||||
|
|
||||||
const navigateBackToMonth = useCallback(() => {
|
const navigateBackToMonth = useCallback(() => {
|
||||||
@@ -425,6 +465,19 @@ export default function CalendarPage() {
|
|||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, [selectedDate, setSelectedDate]);
|
}, [selectedDate, setSelectedDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!newEventPrefill) return;
|
||||||
|
setEditEvent(null);
|
||||||
|
if (newEventPrefill.date) {
|
||||||
|
const d = new Date(newEventPrefill.date);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
setDefaultModalDate(d);
|
||||||
|
setSelectedDate(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowEventModal(true);
|
||||||
|
}, [newEventPrefill, setSelectedDate]);
|
||||||
|
|
||||||
const openEditModal = useCallback((event: CalendarEvent) => {
|
const openEditModal = useCallback((event: CalendarEvent) => {
|
||||||
setEditEvent(event);
|
setEditEvent(event);
|
||||||
setDefaultModalDate(undefined);
|
setDefaultModalDate(undefined);
|
||||||
@@ -553,12 +606,15 @@ export default function CalendarPage() {
|
|||||||
}, [events, client]);
|
}, [events, client]);
|
||||||
|
|
||||||
const refetchCurrentRange = useCallback(async () => {
|
const refetchCurrentRange = useCallback(async () => {
|
||||||
if (!client) return;
|
if (!client || !activeAccountId) return;
|
||||||
const { dateRange: currentRange } = useCalendarStore.getState();
|
const { dateRange: currentRange } = useCalendarStore.getState();
|
||||||
if (currentRange) {
|
if (!currentRange) return;
|
||||||
await fetchEvents(client, currentRange.start, currentRange.end);
|
if (multiAccountEnabled && accountClients.length > 0) {
|
||||||
|
await fetchAllAccountsEventsFn(accountClients, activeAccountId, currentRange.start, currentRange.end);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||||
|
}, [client, fetchEvents, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsEventsFn]);
|
||||||
|
|
||||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
// and refresh calendar data via JMAP instead of reloading the page.
|
// and refresh calendar data via JMAP instead of reloading the page.
|
||||||
@@ -566,8 +622,11 @@ export default function CalendarPage() {
|
|||||||
enabled: isAuthenticated && !!client,
|
enabled: isAuthenticated && !!client,
|
||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
|
||||||
|
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
|
||||||
|
: fetchCalendars(client);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchCalendars(client),
|
calendarRefresh,
|
||||||
refetchCurrentRange(),
|
refetchCurrentRange(),
|
||||||
refreshAllSubscriptions(client),
|
refreshAllSubscriptions(client),
|
||||||
]);
|
]);
|
||||||
@@ -996,10 +1055,47 @@ export default function CalendarPage() {
|
|||||||
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Apply each shared calendar's local color override (per-viewer recolor,
|
||||||
|
// #345). The override replaces the calendar's color and wins over per-event
|
||||||
|
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
|
||||||
|
// calendars are passed through untouched.
|
||||||
|
const displayCalendars = useMemo(() => {
|
||||||
|
return calendars.map((cal) => {
|
||||||
|
if (!cal.isShared) return cal;
|
||||||
|
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||||
|
if (!override) return cal;
|
||||||
|
return { ...cal, color: override, colorIsLocalOverride: true };
|
||||||
|
});
|
||||||
|
}, [calendars, sharedCalendarColors]);
|
||||||
|
|
||||||
|
// Auto-assign a random, not-yet-used palette color to any freshly shared
|
||||||
|
// calendar so multiple shared calendars don't collide on one color. Runs
|
||||||
|
// once per calendar (guarded by the presence of an existing key), and the
|
||||||
|
// user can still overwrite it from the sidebar.
|
||||||
|
useEffect(() => {
|
||||||
|
const shared = calendars.filter((c) => c.isShared);
|
||||||
|
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
// Seed "used" with personal calendar colors plus already-assigned shared
|
||||||
|
// overrides so the picks stay distinct from what's already on screen.
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const c of calendars) {
|
||||||
|
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
|
||||||
|
}
|
||||||
|
for (const color of Object.values(sharedCalendarColors)) {
|
||||||
|
if (color) used.add(color.toLowerCase());
|
||||||
|
}
|
||||||
|
for (const cal of missing) {
|
||||||
|
const color = pickUnusedCalendarColor(used);
|
||||||
|
used.add(color.toLowerCase());
|
||||||
|
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||||
|
}
|
||||||
|
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
|
||||||
|
|
||||||
const allCalendars = useMemo(() => {
|
const allCalendars = useMemo(() => {
|
||||||
if (!showBirthdayCalendar) return calendars;
|
if (!showBirthdayCalendar) return displayCalendars;
|
||||||
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||||
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||||
|
|
||||||
const visibleEvents = useMemo(() => {
|
const visibleEvents = useMemo(() => {
|
||||||
const filtered = events.filter((e) => {
|
const filtered = events.filter((e) => {
|
||||||
@@ -1069,13 +1165,13 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-3">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
||||||
<span className="text-left">
|
<span className="text-start">
|
||||||
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
||||||
<span className="text-left">
|
<span className="text-start">
|
||||||
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1089,6 +1185,7 @@ export default function CalendarPage() {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
|
if (!calendarEnabled) return null;
|
||||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||||
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
@@ -1115,6 +1212,9 @@ export default function CalendarPage() {
|
|||||||
onContextMenuEvent={handleContextMenuEvent}
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
onContextMenuEmpty={handleContextMenuEmpty}
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
|
onEditEvent={openEditModal}
|
||||||
|
onDeleteEvent={handleDeleteContextMenu}
|
||||||
|
onDuplicateEvent={handleDuplicateContextMenu}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
pendingPreview={pendingPreview}
|
pendingPreview={pendingPreview}
|
||||||
@@ -1185,7 +1285,7 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
<TaskListView
|
<TaskListView
|
||||||
tasks={taskStore.tasks}
|
tasks={taskStore.tasks}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
filter={taskStore.filter}
|
filter={taskStore.filter}
|
||||||
showCompleted={taskStore.showCompleted}
|
showCompleted={taskStore.showCompleted}
|
||||||
@@ -1216,9 +1316,11 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
{/* Left Navigation Rail */}
|
<AppTopBannerSlot />
|
||||||
{!isMobile && (
|
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||||
|
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
|
||||||
|
{!isMobile && !isEmbedded && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
@@ -1237,15 +1339,31 @@ export default function CalendarPage() {
|
|||||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar - full height */}
|
{/* Narrow-pane backdrop: dim and close overlay sidebar */}
|
||||||
{!isMobile && !inlineApp && (
|
{isNarrow && narrowSidebarOpen && !inlineApp && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inset-0 bg-black/50 z-40",
|
||||||
|
isEmbedded ? "absolute" : "fixed"
|
||||||
|
)}
|
||||||
|
onClick={() => setNarrowSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
|
||||||
|
{!inlineApp && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
"border-e border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||||
!isResizing && "transition-[width] duration-300"
|
!isResizing && "transition-[width] duration-300",
|
||||||
|
isNarrow && cn(
|
||||||
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
|
"transform transition-transform duration-300 ease-in-out",
|
||||||
|
!narrowSidebarOpen && "-translate-x-full"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
style={{ width: `${calSidebarWidth}px` }}
|
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
<MiniCalendar
|
<MiniCalendar
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
@@ -1265,8 +1383,21 @@ export default function CalendarPage() {
|
|||||||
updateSetting('birthdayCalendarColor', color);
|
updateSetting('birthdayCalendarColor', color);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Shared calendars: recolor locally only (the viewer usually
|
||||||
|
// can't write the owner's calendar, and it'd recolor it for
|
||||||
|
// everyone). Personal calendars write through to the server.
|
||||||
|
const cal = allCalendars.find((c) => c.id === calendarId);
|
||||||
|
if (cal?.isShared) {
|
||||||
|
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onResetColor={(cal) => {
|
||||||
|
// Drop the local override; the auto-assign effect picks a
|
||||||
|
// fresh unused color (so it never reverts to a collision).
|
||||||
|
removeSharedCalendarColor(sharedCalendarColorKey(cal));
|
||||||
|
}}
|
||||||
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||||
onCreateEvent={(cal: Calendar) => {
|
onCreateEvent={(cal: Calendar) => {
|
||||||
setDefaultCalendarIdForCreate(cal.id);
|
setDefaultCalendarIdForCreate(cal.id);
|
||||||
@@ -1306,17 +1437,20 @@ export default function CalendarPage() {
|
|||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
|
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
{!isNarrow && (
|
||||||
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
|
<ResizeHandle
|
||||||
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
|
||||||
onResizeEnd={() => {
|
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||||
setIsResizing(false);
|
onResizeEnd={() => {
|
||||||
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
|
setIsResizing(false);
|
||||||
}}
|
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
|
||||||
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
|
}}
|
||||||
/>
|
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1334,10 +1468,11 @@ export default function CalendarPage() {
|
|||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
onToggleVisibility={toggleCalendarVisibility}
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
enableCalendarTasks={enableCalendarTasks}
|
enableCalendarTasks={enableCalendarTasks}
|
||||||
|
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -1359,11 +1494,11 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Desktop event panel */}
|
{/* Desktop event panel */}
|
||||||
{!isMobile && showEventModal && (
|
{!isMobile && showEventModal && (
|
||||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||||
<EventModal
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
defaultAllDay={defaultModalAllDay}
|
defaultAllDay={defaultModalAllDay}
|
||||||
@@ -1372,21 +1507,25 @@ export default function CalendarPage() {
|
|||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }}
|
||||||
onPreviewChange={setPendingPreview}
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
|
prefillTitle={editEvent ? undefined : newEventPrefill?.title}
|
||||||
|
prefillDescription={editEvent ? undefined : newEventPrefill?.description}
|
||||||
|
prefillParticipants={editEvent ? undefined : newEventPrefill?.participants}
|
||||||
|
prefillDate={editEvent ? undefined : newEventPrefill?.date}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop task panel */}
|
{/* Desktop task panel */}
|
||||||
{!isMobile && showTaskModal && (
|
{!isMobile && showTaskModal && (
|
||||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||||
<TaskModal
|
<TaskModal
|
||||||
key={editTask?.id ?? 'new-task'}
|
key={editTask?.id ?? 'new-task'}
|
||||||
task={editTask}
|
task={editTask}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
onSave={handleSaveTask}
|
onSave={handleSaveTask}
|
||||||
onDelete={handleDeleteTask}
|
onDelete={handleDeleteTask}
|
||||||
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||||
@@ -1410,7 +1549,7 @@ export default function CalendarPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
{isMobile && (
|
{isMobile && !isEmbedded && (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
@@ -1473,7 +1612,7 @@ export default function CalendarPage() {
|
|||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
<EventDetailPopover
|
<EventDetailPopover
|
||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
|
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
|
||||||
anchorRect={detailAnchorRect}
|
anchorRect={detailAnchorRect}
|
||||||
onEdit={handleEditFromDetail}
|
onEdit={handleEditFromDetail}
|
||||||
onDelete={handleDeleteFromDetail}
|
onDelete={handleDeleteFromDetail}
|
||||||
@@ -1493,7 +1632,7 @@ export default function CalendarPage() {
|
|||||||
<EventModal
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
defaultAllDay={defaultModalAllDay}
|
defaultAllDay={defaultModalAllDay}
|
||||||
@@ -1502,15 +1641,19 @@ export default function CalendarPage() {
|
|||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={true}
|
isMobile={true}
|
||||||
|
prefillTitle={editEvent ? undefined : newEventPrefill?.title}
|
||||||
|
prefillDescription={editEvent ? undefined : newEventPrefill?.description}
|
||||||
|
prefillParticipants={editEvent ? undefined : newEventPrefill?.participants}
|
||||||
|
prefillDate={editEvent ? undefined : newEventPrefill?.date}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showImportModal && client && (
|
{showImportModal && client && (
|
||||||
<ICalImportModal
|
<ICalImportModal
|
||||||
calendars={calendars}
|
calendars={displayCalendars}
|
||||||
client={client}
|
client={client}
|
||||||
initialUrl={pendingSubscription?.url}
|
initialUrl={pendingSubscription?.url}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -1580,6 +1723,7 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ArrowLeft, Users } from "lucide-react";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { ArrowLeft, Users, AlertTriangle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
@@ -15,17 +17,23 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con
|
|||||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
|
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
|
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
|
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { cn, generateUUID } from "@/lib/utils";
|
import { cn, generateUUID } from "@/lib/utils";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
|
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
@@ -42,6 +50,7 @@ type View =
|
|||||||
|
|
||||||
export default function ContactsPage() {
|
export default function ContactsPage() {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -75,6 +84,7 @@ export default function ContactsPage() {
|
|||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
|
createAddressBook,
|
||||||
renameAddressBook,
|
renameAddressBook,
|
||||||
removeAddressBook,
|
removeAddressBook,
|
||||||
shareAddressBook,
|
shareAddressBook,
|
||||||
@@ -86,13 +96,29 @@ export default function ContactsPage() {
|
|||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||||
|
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
|
||||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||||
|
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||||
|
const [returnToEmail, setReturnToEmail] = useState(false);
|
||||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
// One-shot intent flag: only consume the URL params on the first render that
|
||||||
|
// has them. After applying, we strip the query so a later refresh or
|
||||||
|
// re-mount doesn't re-trigger the navigation.
|
||||||
|
const intentAppliedRef = useRef(false);
|
||||||
|
// Narrow pane (Pro split or small window): the categories sidebar collapses
|
||||||
|
// into a burger-toggled overlay.
|
||||||
|
const isNarrow = !isDesktop;
|
||||||
|
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||||
|
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||||
|
|
||||||
// Panel resize state - sidebar (categories)
|
// Panel resize state - sidebar (categories)
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
@@ -129,12 +155,42 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
|
// Pro shell only: aggregate contacts and address books from every
|
||||||
|
// connected account so the sidebar lists them all. The hook is a no-op
|
||||||
|
// outside the embedded shell.
|
||||||
|
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEmbedded) return;
|
||||||
if (client && supportsSync && !hasFetched.current) {
|
if (client && supportsSync && !hasFetched.current) {
|
||||||
hasFetched.current = true;
|
hasFetched.current = true;
|
||||||
fetchContacts(client);
|
fetchContacts(client);
|
||||||
}
|
}
|
||||||
}, [client, supportsSync, fetchContacts]);
|
}, [client, supportsSync, fetchContacts, isEmbedded]);
|
||||||
|
|
||||||
|
// Consume one-shot URL params (set by the mobile recipient popover when no
|
||||||
|
// sidebar is available) and strip them so a refresh doesn't replay the
|
||||||
|
// intent. `from=email` flips the mobile back button to `router.back()`.
|
||||||
|
useEffect(() => {
|
||||||
|
if (intentAppliedRef.current) return;
|
||||||
|
const contactId = searchParams.get('contactId');
|
||||||
|
const addEmail = searchParams.get('addEmail');
|
||||||
|
const addName = searchParams.get('addName');
|
||||||
|
const from = searchParams.get('from');
|
||||||
|
const viewParam = searchParams.get('view');
|
||||||
|
if (!contactId && !addEmail && !from) return;
|
||||||
|
intentAppliedRef.current = true;
|
||||||
|
if (from === 'email') setReturnToEmail(true);
|
||||||
|
if (contactId) {
|
||||||
|
setSelectedContact(contactId);
|
||||||
|
setView(viewParam === 'edit' ? 'edit' : 'detail');
|
||||||
|
} else if (addEmail) {
|
||||||
|
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
|
||||||
|
setSelectedContact(null);
|
||||||
|
setView('create');
|
||||||
|
}
|
||||||
|
router.replace('/contacts');
|
||||||
|
}, [searchParams, router, setSelectedContact]);
|
||||||
|
|
||||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
// and refresh contacts via JMAP instead of reloading the page.
|
// and refresh contacts via JMAP instead of reloading the page.
|
||||||
@@ -142,6 +198,17 @@ export default function ContactsPage() {
|
|||||||
enabled: isAuthenticated && !!client && supportsSync,
|
enabled: isAuthenticated && !!client && supportsSync,
|
||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (multiAccountEnabled && accountClients.length > 0) {
|
||||||
|
const activeId = useAuthStore.getState().activeAccountId;
|
||||||
|
if (activeId) {
|
||||||
|
const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||||
|
await Promise.all([
|
||||||
|
fetchAllAccountsAddressBooks(accountClients, activeId),
|
||||||
|
fetchAllAccountsContacts(accountClients, activeId),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
await fetchContacts(client);
|
await fetchContacts(client);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -193,6 +260,7 @@ export default function ContactsPage() {
|
|||||||
} else {
|
} else {
|
||||||
setSelectedGroupId(null);
|
setSelectedGroupId(null);
|
||||||
}
|
}
|
||||||
|
setNarrowSidebarOpen(false);
|
||||||
}, [clearSelection]);
|
}, [clearSelection]);
|
||||||
|
|
||||||
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
||||||
@@ -234,6 +302,34 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
|
// Refresh address books (and contacts) after a structural change, staying
|
||||||
|
// multi-account aware so a freshly created book lands in the sidebar.
|
||||||
|
const refreshAddressBooks = useCallback(async () => {
|
||||||
|
if (!client) return;
|
||||||
|
if (multiAccountEnabled && accountClients.length > 0) {
|
||||||
|
const activeId = useAuthStore.getState().activeAccountId;
|
||||||
|
if (activeId) {
|
||||||
|
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||||
|
await fetchAllAccountsAddressBooks(accountClients, activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await useContactStore.getState().fetchAddressBooks(client);
|
||||||
|
}, [client, multiAccountEnabled, accountClients]);
|
||||||
|
|
||||||
|
const handleCreateAddressBook = useCallback(async (name: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await createAddressBook(client, name);
|
||||||
|
await refreshAddressBooks();
|
||||||
|
toast.success(t("address_books.created"));
|
||||||
|
setCreatingAddressBook(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create address book:', error);
|
||||||
|
toast.error(t("address_books.create_failed"));
|
||||||
|
}
|
||||||
|
}, [client, createAddressBook, refreshAddressBooks, t]);
|
||||||
|
|
||||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||||
return importContacts(
|
return importContacts(
|
||||||
supportsSync && client ? client : null,
|
supportsSync && client ? client : null,
|
||||||
@@ -304,8 +400,8 @@ export default function ContactsPage() {
|
|||||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||||
|
|
||||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||||
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
|
||||||
void _id; void _created; void _updated;
|
void _id; void _uid; void _created; void _updated;
|
||||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||||
if (supportsSync && client) {
|
if (supportsSync && client) {
|
||||||
await createContact(client, data);
|
await createContact(client, data);
|
||||||
@@ -335,8 +431,14 @@ export default function ContactsPage() {
|
|||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
}
|
}
|
||||||
setDefaultBookIdForCreate(undefined);
|
setDefaultBookIdForCreate(undefined);
|
||||||
|
setCreatePrefill(undefined);
|
||||||
|
if (returnToEmail) {
|
||||||
|
setReturnToEmail(false);
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setView("list");
|
setView("list");
|
||||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
|
||||||
|
|
||||||
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
|
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
|
||||||
if (!selectedContact) return;
|
if (!selectedContact) return;
|
||||||
@@ -353,6 +455,14 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setDefaultBookIdForCreate(undefined);
|
setDefaultBookIdForCreate(undefined);
|
||||||
|
// Came from email → cancel returns to the email instead of the contact list.
|
||||||
|
if (returnToEmail && view === "create") {
|
||||||
|
setCreatePrefill(undefined);
|
||||||
|
setReturnToEmail(false);
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (view === "create") setCreatePrefill(undefined);
|
||||||
if (view === "group-create" || view === "group-edit") {
|
if (view === "group-create" || view === "group-edit") {
|
||||||
setView(selectedGroup ? "group-detail" : "list");
|
setView(selectedGroup ? "group-detail" : "list");
|
||||||
} else if (view === "bulk-add-to-group") {
|
} else if (view === "bulk-add-to-group") {
|
||||||
@@ -383,6 +493,59 @@ export default function ContactsPage() {
|
|||||||
setView("group-edit");
|
setView("group-edit");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Open the in-app composer in the current session rather than routing through
|
||||||
|
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
|
||||||
|
// (which may open a different mail app), and the mailto protocol round-trip
|
||||||
|
// reloads the app - dropping the in-memory per-account JMAP clients of a
|
||||||
|
// multi-account session, which reads as a logout. Stashing the recipients and
|
||||||
|
// doing a client-side router.push keeps the session and the active account
|
||||||
|
// intact; the main route consumes the pending compose and opens the composer
|
||||||
|
// (see consumePendingMailto in page.tsx).
|
||||||
|
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
|
||||||
|
savePendingMailto({
|
||||||
|
to: field === "to" ? recipients : [],
|
||||||
|
cc: field === "cc" ? recipients : [],
|
||||||
|
bcc: field === "bcc" ? recipients : [],
|
||||||
|
subject: "",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
router.push("/");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||||
|
// Hand the composer a single group chip (RFC 5322 group syntax survives
|
||||||
|
// the string hand-off) instead of one entry per member - the chip expands
|
||||||
|
// into the members when the message is sent. Dedupe by email,
|
||||||
|
// case-insensitively; members without an email are skipped.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const members: Array<{ name?: string; email: string }> = [];
|
||||||
|
for (const member of getGroupMembers(groupId)) {
|
||||||
|
const email = getContactPrimaryEmail(member).trim();
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
if (!email || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
const name = getContactDisplayName(member);
|
||||||
|
members.push({ name: name && name !== email ? name : undefined, email });
|
||||||
|
}
|
||||||
|
if (members.length === 0) {
|
||||||
|
toast.error(t("groups.no_member_emails"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group = useContactStore.getState().contacts.find((c) => c.id === groupId);
|
||||||
|
const chip: Recipient = {
|
||||||
|
name: (group && getContactDisplayName(group)) || "Group",
|
||||||
|
email: "",
|
||||||
|
group: { members },
|
||||||
|
};
|
||||||
|
openComposeInApp([formatRecipientEntry(chip)], field);
|
||||||
|
}, [getGroupMembers, t, openComposeInApp]);
|
||||||
|
|
||||||
|
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||||
|
const email = getContactPrimaryEmail(contact).trim();
|
||||||
|
if (!email) return;
|
||||||
|
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
|
||||||
|
}, [openComposeInApp]);
|
||||||
|
|
||||||
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t("groups.delete_confirm_title"),
|
title: t("groups.delete_confirm_title"),
|
||||||
@@ -524,7 +687,7 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
@@ -547,6 +710,7 @@ export default function ContactsPage() {
|
|||||||
onEdit={handleEditGroup}
|
onEdit={handleEditGroup}
|
||||||
onDelete={handleDeleteGroup}
|
onDelete={handleDeleteGroup}
|
||||||
onRemoveMember={handleRemoveGroupMember}
|
onRemoveMember={handleRemoveGroupMember}
|
||||||
|
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
@@ -595,7 +759,7 @@ export default function ContactsPage() {
|
|||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => handleBulkAddToGroupConfirm(group.id)}
|
onClick={() => handleBulkAddToGroupConfirm(group.id)}
|
||||||
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
|
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
<Users className="w-4 h-4 text-primary" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
@@ -624,6 +788,11 @@ export default function ContactsPage() {
|
|||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onCompose={
|
||||||
|
selectedContact
|
||||||
|
? () => handleComposeContact(selectedContact)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onAddToGroup={
|
onAddToGroup={
|
||||||
selectedContact
|
selectedContact
|
||||||
? () => handleAddContactToGroup(selectedContact.id)
|
? () => handleAddContactToGroup(selectedContact.id)
|
||||||
@@ -640,18 +809,38 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!contactsEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh items-center justify-center bg-background p-6">
|
||||||
|
<div className="max-w-lg text-center space-y-3">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||||
|
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const showListPanel = !isMobile || view === "list";
|
const showListPanel = !isMobile || view === "list";
|
||||||
const showRightPanel = !isMobile || view !== "list";
|
const showRightPanel = !isMobile || view !== "list";
|
||||||
|
|
||||||
const mobileBackToList = () => {
|
const mobileBackToList = () => {
|
||||||
|
if (returnToEmail) {
|
||||||
|
setReturnToEmail(false);
|
||||||
|
setCreatePrefill(undefined);
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setView("list");
|
setView("list");
|
||||||
clearSelection();
|
clearSelection();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
{/* Navigation Rail - desktop only */}
|
<AppTopBannerSlot />
|
||||||
{!isMobile && (
|
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||||
|
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
|
||||||
|
{!isMobile && !isEmbedded && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
@@ -670,18 +859,33 @@ export default function ContactsPage() {
|
|||||||
{inlineApp && (
|
{inlineApp && (
|
||||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||||
)}
|
)}
|
||||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
<div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||||
|
{/* Narrow-pane backdrop for the overlay categories sidebar */}
|
||||||
|
{isNarrow && narrowSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inset-0 bg-black/50 z-40",
|
||||||
|
isEmbedded ? "absolute" : "fixed"
|
||||||
|
)}
|
||||||
|
onClick={() => setNarrowSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showListPanel && (
|
{showListPanel && (
|
||||||
<>
|
<>
|
||||||
{/* Panel 1: Categories sidebar */}
|
{/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
|
||||||
{!isMobile && (
|
{(!isMobile || isNarrow) && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border flex flex-col flex-shrink-0",
|
"border-e border-border flex flex-col flex-shrink-0 bg-background",
|
||||||
!isSidebarResizing && "transition-[width] duration-300"
|
!isSidebarResizing && "transition-[width] duration-300",
|
||||||
|
isNarrow && cn(
|
||||||
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
|
"transform transition-transform duration-300 ease-in-out",
|
||||||
|
!narrowSidebarOpen && "-translate-x-full"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
style={{ width: `${sidebarWidth}px` }}
|
style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
<ContactsSidebar
|
<ContactsSidebar
|
||||||
groups={groups}
|
groups={groups}
|
||||||
@@ -691,9 +895,11 @@ export default function ContactsPage() {
|
|||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
onCreateContact={handleCreateNew}
|
onCreateContact={handleCreateNew}
|
||||||
|
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
|
||||||
onImport={() => setShowImportDialog(true)}
|
onImport={() => setShowImportDialog(true)}
|
||||||
onEditGroup={handleEditGroupFromSidebar}
|
onEditGroup={handleEditGroupFromSidebar}
|
||||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
|
onComposeGroup={handleComposeGroupFromSidebar}
|
||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
@@ -718,17 +924,20 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||||
|
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
{!isNarrow && (
|
||||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
<ResizeHandle
|
||||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||||
onResizeEnd={() => {
|
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||||
setIsSidebarResizing(false);
|
onResizeEnd={() => {
|
||||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
setIsSidebarResizing(false);
|
||||||
}}
|
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
}}
|
||||||
/>
|
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -736,7 +945,7 @@ export default function ContactsPage() {
|
|||||||
<div
|
<div
|
||||||
data-tour="contacts-list"
|
data-tour="contacts-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
"border-e border-border bg-background flex flex-col flex-shrink-0",
|
||||||
isMobile ? "w-full" : "",
|
isMobile ? "w-full" : "",
|
||||||
!isListResizing && !isMobile && "transition-[width] duration-300"
|
!isListResizing && !isMobile && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
@@ -761,6 +970,7 @@ export default function ContactsPage() {
|
|||||||
onEditContact={handleEditContact}
|
onEditContact={handleEditContact}
|
||||||
onDeleteContact={handleDeleteContact}
|
onDeleteContact={handleDeleteContact}
|
||||||
onAddContactToGroup={handleAddContactToGroup}
|
onAddContactToGroup={handleAddContactToGroup}
|
||||||
|
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -789,8 +999,8 @@ export default function ContactsPage() {
|
|||||||
onClick={mobileBackToList}
|
onClick={mobileBackToList}
|
||||||
className="touch-manipulation"
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
{t("back_to_contacts")}
|
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -801,7 +1011,7 @@ export default function ContactsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && !isEmbedded && (
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
@@ -835,6 +1045,15 @@ export default function ContactsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{creatingAddressBook && (
|
||||||
|
<RenameDialog
|
||||||
|
currentName=""
|
||||||
|
title={t("address_books.create")}
|
||||||
|
label={t("address_books.name_label")}
|
||||||
|
onCancel={() => setCreatingAddressBook(false)}
|
||||||
|
onConfirm={handleCreateAddressBook}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{renamingAddressBook && (
|
{renamingAddressBook && (
|
||||||
<RenameDialog
|
<RenameDialog
|
||||||
currentName={renamingAddressBook.name}
|
currentName={renamingAddressBook.name}
|
||||||
@@ -882,6 +1101,7 @@ export default function ContactsPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,11 +38,11 @@ export default function LocaleError({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Button variant="outline" onClick={() => router.push('/')}>
|
<Button variant="outline" onClick={() => router.push('/')}>
|
||||||
<Home className="w-4 h-4 mr-2" />
|
<Home className="w-4 h-4 me-2" />
|
||||||
{t("go_home")}
|
{t("go_home")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={reset}>
|
<Button onClick={reset}>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 me-2" />
|
||||||
{t("try_again")}
|
{t("try_again")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -8,29 +8,37 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { FileBrowser } from "@/components/files/file-browser";
|
import { FileBrowser } from "@/components/files/file-browser";
|
||||||
|
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
|
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
@@ -42,9 +50,11 @@ export default function FilesPage() {
|
|||||||
supportsFiles,
|
supportsFiles,
|
||||||
selectedResources,
|
selectedResources,
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
|
migrationProgress,
|
||||||
clipboard,
|
clipboard,
|
||||||
initClient,
|
initClient,
|
||||||
checkSupport,
|
checkSupport,
|
||||||
|
migrateLegacyFlatNodes,
|
||||||
navigate,
|
navigate,
|
||||||
navigateByPath,
|
navigateByPath,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -80,9 +90,11 @@ export default function FilesPage() {
|
|||||||
cancelUpload,
|
cancelUpload,
|
||||||
undoLastAction,
|
undoLastAction,
|
||||||
lastAction,
|
lastAction,
|
||||||
|
shareResource,
|
||||||
} = useFileStore();
|
} = useFileStore();
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
@@ -127,13 +139,18 @@ export default function FilesPage() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
// Initialize JMAP files client
|
// Initialize JMAP files client. In the Pro shell, all connected accounts
|
||||||
|
// are surfaced as top-level folders at the root, so we *don't* auto-attach
|
||||||
|
// to the active account - the user picks one explicitly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && client && !hasFetched.current) {
|
if (!isAuthenticated || !client || hasFetched.current) return;
|
||||||
hasFetched.current = true;
|
hasFetched.current = true;
|
||||||
initClient(client);
|
if (isEmbedded) {
|
||||||
|
useFileStore.getState().clearClient();
|
||||||
|
} else {
|
||||||
|
initClient(client, activeAccountId);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, client, initClient]);
|
}, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]);
|
||||||
|
|
||||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
// and refresh files via JMAP instead of reloading the page.
|
// and refresh files via JMAP instead of reloading the page.
|
||||||
@@ -148,15 +165,29 @@ export default function FilesPage() {
|
|||||||
const storeClient = useFileStore(s => s.client);
|
const storeClient = useFileStore(s => s.client);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (storeClient && supportsFiles === null) {
|
if (storeClient && supportsFiles === null) {
|
||||||
checkSupport().then((supported) => {
|
checkSupport().then(async (supported) => {
|
||||||
if (supported) {
|
if (supported) {
|
||||||
|
// Upgrade any files created by older builds (flat path-encoded names)
|
||||||
|
// into the real FileNode hierarchy before the first listing.
|
||||||
|
await migrateLegacyFlatNodes();
|
||||||
navigate(null);
|
navigate(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
|
||||||
|
|
||||||
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
||||||
|
// Pro shell only: the Account breadcrumb segment signals "go to this
|
||||||
|
// account's filesystem root" via a sentinel, distinguishing it from a
|
||||||
|
// Home click (which detaches the account and returns to the picker).
|
||||||
|
if (resourceId === '__account_root__') {
|
||||||
|
void navigate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEmbedded && path === '/' && resourceId === undefined) {
|
||||||
|
useFileStore.getState().clearClient();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (resourceId !== undefined) {
|
if (resourceId !== undefined) {
|
||||||
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
|
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
|
||||||
navigate(resourceId, path.split('/').pop() || '');
|
navigate(resourceId, path.split('/').pop() || '');
|
||||||
@@ -164,7 +195,7 @@ export default function FilesPage() {
|
|||||||
// Path-based navigation (breadcrumbs, favorites, recent files)
|
// Path-based navigation (breadcrumbs, favorites, recent files)
|
||||||
navigateByPath(path);
|
navigateByPath(path);
|
||||||
}
|
}
|
||||||
}, [navigate, navigateByPath]);
|
}, [navigate, navigateByPath, isEmbedded]);
|
||||||
|
|
||||||
const handleCreateFolder = useCallback(async (name: string) => {
|
const handleCreateFolder = useCallback(async (name: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -371,11 +402,78 @@ export default function FilesPage() {
|
|||||||
setShowDetails(v => !v);
|
setShowDetails(v => !v);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
||||||
|
|
||||||
|
// Sharing: the browsing client (store-attached) drives the principal picker
|
||||||
|
// and share mutations. supportsPrincipals() gates the whole Share affordance.
|
||||||
|
const sharingEnabled = !!storeClient?.supportsPrincipals();
|
||||||
|
const filesAccountId = storeClient?.getFilesAccountId() ?? null;
|
||||||
|
const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => {
|
||||||
|
await shareResource(id, principalId, rights);
|
||||||
|
}, [shareResource]);
|
||||||
|
|
||||||
|
const handleSendAsAttachment = useCallback((names: string[]) => {
|
||||||
|
const store = useFileStore.getState();
|
||||||
|
const fileAtts = names
|
||||||
|
.map((name) => {
|
||||||
|
const r = store.resources.find((res) => res.name === name);
|
||||||
|
if (!r || r.isDirectory || !r.blobId) return null;
|
||||||
|
return {
|
||||||
|
blobId: r.blobId,
|
||||||
|
name: r.name,
|
||||||
|
type: r.contentType || "application/octet-stream",
|
||||||
|
size: r.contentLength,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||||
|
|
||||||
|
if (fileAtts.length === 0) return;
|
||||||
|
|
||||||
|
useProTabStore.getState().openComposeTab({
|
||||||
|
sessionId: Date.now(),
|
||||||
|
mode: "compose",
|
||||||
|
replyTo: { attachments: fileAtts },
|
||||||
|
title: fileAtts.length === 1 ? fileAtts[0].name : `${fileAtts.length} attachments`,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Pro shell only: all connected accounts are equal top-level entries at
|
||||||
|
// the root. The root path "/" itself is a cross-account picker - no
|
||||||
|
// account's files are shown until the user enters one.
|
||||||
|
const accountFolders = isEmbedded
|
||||||
|
? accounts
|
||||||
|
.filter((a) => a.isConnected)
|
||||||
|
.map((a) => ({
|
||||||
|
accountId: a.id,
|
||||||
|
label: a.label || a.email,
|
||||||
|
email: a.email,
|
||||||
|
avatarColor: a.avatarColor,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const isAccountPicker = isEmbedded && currentFilesAccountId === null;
|
||||||
|
const currentAccountLabel = isEmbedded && currentFilesAccountId
|
||||||
|
? (accounts.find((a) => a.id === currentFilesAccountId)?.label
|
||||||
|
|| accounts.find((a) => a.id === currentFilesAccountId)?.email
|
||||||
|
|| null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSelectAccount = useCallback((accountId: string) => {
|
||||||
|
const nextClient = getClientForAccount(accountId);
|
||||||
|
if (!nextClient) return;
|
||||||
|
const store = useFileStore.getState();
|
||||||
|
store.initClient(nextClient, accountId);
|
||||||
|
// Reset supportsFiles so the existing checkSupport effect re-runs for
|
||||||
|
// the freshly-attached client and triggers the initial navigate(null).
|
||||||
|
useFileStore.setState({ supportsFiles: null });
|
||||||
|
}, [getClientForAccount]);
|
||||||
|
|
||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
{!isMobile && (
|
<AppTopBannerSlot />
|
||||||
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
{!isMobile && !isEmbedded && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
@@ -396,7 +494,7 @@ export default function FilesPage() {
|
|||||||
)}
|
)}
|
||||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
{folderLayout !== "sidebar" && (
|
{folderLayout !== "sidebar" && !isEmbedded && (
|
||||||
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
@@ -405,7 +503,7 @@ export default function FilesPage() {
|
|||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,6 +572,15 @@ export default function FilesPage() {
|
|||||||
showDetails={showDetails}
|
showDetails={showDetails}
|
||||||
onToggleDetails={handleToggleDetails}
|
onToggleDetails={handleToggleDetails}
|
||||||
detailResource={detailResource}
|
detailResource={detailResource}
|
||||||
|
accountFolders={accountFolders}
|
||||||
|
onSelectAccount={handleSelectAccount}
|
||||||
|
accountPickerMode={isAccountPicker}
|
||||||
|
accountLabel={currentAccountLabel}
|
||||||
|
client={storeClient}
|
||||||
|
ownAccountId={filesAccountId}
|
||||||
|
sharingEnabled={sharingEnabled}
|
||||||
|
onShare={handleShare}
|
||||||
|
onSendAsAttachment={handleSendAsAttachment}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -481,7 +588,7 @@ export default function FilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && !isEmbedded && (
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
@@ -512,8 +619,35 @@ export default function FilesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Legacy file migration progress (issue #379) */}
|
||||||
|
{migrationProgress && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="w-[22rem] max-w-[90vw] rounded-lg border border-border bg-background p-6 shadow-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 text-primary animate-spin shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{t("migration_title")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("migration_description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-1.5 bg-primary/20 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||||
|
style={{ width: migrationProgress.total > 0
|
||||||
|
? `${(migrationProgress.current / migrationProgress.total) * 100}%`
|
||||||
|
: '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
|
||||||
|
{migrationProgress.current} / {migrationProgress.total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,11 @@ import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-p
|
|||||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||||
import { TourProvider } from "@/components/tour/tour-provider";
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||||
|
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||||
|
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
|
||||||
|
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||||
|
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||||
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -34,7 +39,12 @@ export default async function LocaleLayout({
|
|||||||
<EmbeddedBridgeProvider>
|
<EmbeddedBridgeProvider>
|
||||||
<TourProvider>
|
<TourProvider>
|
||||||
<ProtocolLaunchHandlerProvider>
|
<ProtocolLaunchHandlerProvider>
|
||||||
|
<ProInterfaceRedirect />
|
||||||
|
<ImpersonationReconciler />
|
||||||
{children}
|
{children}
|
||||||
|
<PluginDialogHost />
|
||||||
|
<PluginConsentDialog />
|
||||||
|
<PWAInstallPrompt />
|
||||||
</ProtocolLaunchHandlerProvider>
|
</ProtocolLaunchHandlerProvider>
|
||||||
</TourProvider>
|
</TourProvider>
|
||||||
</EmbeddedBridgeProvider>
|
</EmbeddedBridgeProvider>
|
||||||
@@ -9,14 +9,14 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
|
||||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||||
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
||||||
|
|
||||||
@@ -109,16 +109,52 @@ function VersionBadge() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only redirect targets matching this scheme are honored by the mobile
|
||||||
|
// handoff path. Without the check the login page becomes an open redirector
|
||||||
|
// that funnels password and token material to any caller-supplied URL.
|
||||||
|
const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
|
|
||||||
|
// When the mobile app launches the webmail in a browser tab it tacks on
|
||||||
|
// these params. We grab them once at mount and stash them in a ref so any
|
||||||
|
// login path that completes (password or OAuth) can hand control back to
|
||||||
|
// the app instead of routing into /mail.
|
||||||
|
const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? "";
|
||||||
|
const rawMobileState = searchParams.get("mobile_state") ?? "";
|
||||||
|
const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)
|
||||||
|
? rawMobileRedirectUri
|
||||||
|
: "";
|
||||||
|
const mobileState = mobileRedirectUri ? rawMobileState : "";
|
||||||
|
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginLogoLightUrlIsCustom, loginLogoDarkUrlIsCustom, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
const { activeThemeId, installedThemes } = useThemeStore(useShallow((s) => ({ activeThemeId: s.activeThemeId, installedThemes: s.installedThemes })));
|
||||||
|
// Active theme may carry its own brand logo (VNClagoon wordmark, SRC mark);
|
||||||
|
// an explicitly-configured logo (Branding tab / LOGIN_LOGO_*_URL) wins
|
||||||
|
// over that, falling back to the theme's logo only when nothing was set.
|
||||||
|
const effLoginLogo = resolveThemeLogo(
|
||||||
|
installedThemes,
|
||||||
|
activeThemeId,
|
||||||
|
resolvedTheme === 'dark',
|
||||||
|
loginLogoLightUrl,
|
||||||
|
loginLogoDarkUrl,
|
||||||
|
loginLogoLightUrlIsCustom || loginLogoDarkUrlIsCustom,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Login logo sizing: when a max height/width is configured, drop the fixed
|
||||||
|
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
|
||||||
|
const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth);
|
||||||
|
const loginLogoStyle = hasLogoSize
|
||||||
|
? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
@@ -160,6 +196,9 @@ export default function LoginPage() {
|
|||||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||||
const prevError = useRef<string | null>(null);
|
const prevError = useRef<string | null>(null);
|
||||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Captured by handleSubmit when in mobile handoff mode; consumed by the
|
||||||
|
// isAuthenticated effect to build the deep-link fragment.
|
||||||
|
const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeTheme();
|
initializeTheme();
|
||||||
@@ -239,6 +278,19 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && !isAddAccountMode) {
|
if (isAuthenticated && !isAddAccountMode) {
|
||||||
|
// Mobile handoff: the password path completes here once the auth store
|
||||||
|
// flips isAuthenticated. Hand the verified credentials back to the
|
||||||
|
// mobile app instead of pushing to /mail. handleSubmit captured the
|
||||||
|
// values needed for the fragment.
|
||||||
|
if (isMobileHandoff && mobileHandoffPayloadRef.current) {
|
||||||
|
const fragment = new URLSearchParams({
|
||||||
|
flow: "password",
|
||||||
|
...mobileHandoffPayloadRef.current,
|
||||||
|
state: mobileState,
|
||||||
|
});
|
||||||
|
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let redirectTo = '/';
|
let redirectTo = '/';
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
@@ -247,9 +299,9 @@ export default function LoginPage() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router, isAddAccountMode]);
|
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -297,16 +349,27 @@ export default function LoginPage() {
|
|||||||
if (!oauthEnabled || !serverUrl) return;
|
if (!oauthEnabled || !serverUrl) return;
|
||||||
setOauthDiscoveryDone(false);
|
setOauthDiscoveryDone(false);
|
||||||
setOauthMetadata(null);
|
setOauthMetadata(null);
|
||||||
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
|
const controller = new AbortController();
|
||||||
|
// Discover via our own origin rather than fetching the IdP's /.well-known/*
|
||||||
|
// documents directly from the browser. A direct cross-origin discovery
|
||||||
|
// fetch is subject to CORS, and providers like Authentik serve those
|
||||||
|
// documents without Access-Control-Allow-Origin, so the browser blocks the
|
||||||
|
// response and login breaks (issue #382). The proxy runs discovery server
|
||||||
|
// side where CORS does not apply.
|
||||||
|
const query = selectedServer?.id ? `?server_id=${encodeURIComponent(selectedServer.id)}` : "";
|
||||||
|
apiFetch(`/api/auth/oauth/metadata${query}`, { signal: controller.signal })
|
||||||
|
.then(async (res) => (res.ok ? ((await res.json()) as OAuthMetadata) : null))
|
||||||
.then((metadata) => {
|
.then((metadata) => {
|
||||||
setOauthMetadata(metadata);
|
setOauthMetadata(metadata);
|
||||||
setOauthDiscoveryDone(true);
|
setOauthDiscoveryDone(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
setOauthMetadata(null);
|
setOauthMetadata(null);
|
||||||
setOauthDiscoveryDone(true);
|
setOauthDiscoveryDone(true);
|
||||||
});
|
});
|
||||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
|
return () => controller.abort();
|
||||||
|
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
|
||||||
|
|
||||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||||
const ssoError = searchParams.get("sso_error");
|
const ssoError = searchParams.get("sso_error");
|
||||||
@@ -317,6 +380,16 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const prefix = getPathPrefix(params.locale as string);
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
|
// In mobile-handoff mode the callback page needs to know it should
|
||||||
|
// redirect into the app rather than into /mail. Stash the params in
|
||||||
|
// sessionStorage so the same-tab callback can read them - the SSO
|
||||||
|
// pending cookie carries the authoritative copy server-side too.
|
||||||
|
if (isMobileHandoff) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri);
|
||||||
|
sessionStorage.setItem("mobile_state", mobileState);
|
||||||
|
} catch { /* sessionStorage unavailable */ }
|
||||||
|
}
|
||||||
const res = await apiFetch('/api/auth/sso/start', {
|
const res = await apiFetch('/api/auth/sso/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -325,6 +398,9 @@ export default function LoginPage() {
|
|||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
locale: params.locale,
|
locale: params.locale,
|
||||||
server_id: selectedServer?.id,
|
server_id: selectedServer?.id,
|
||||||
|
...(isMobileHandoff
|
||||||
|
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -351,7 +427,7 @@ export default function LoginPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setOauthLoading(false);
|
setOauthLoading(false);
|
||||||
}
|
}
|
||||||
}, [params.locale, selectedServer?.id]);
|
}, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||||
@@ -493,6 +569,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const handleOAuthLogin = async () => {
|
const handleOAuthLogin = async () => {
|
||||||
if (!oauthMetadata || !effectiveOauthClientId) return;
|
if (!oauthMetadata || !effectiveOauthClientId) return;
|
||||||
|
// In mobile-handoff mode the client-side PKCE flow doesn't help us:
|
||||||
|
// tokens would land in sessionStorage on the webmail origin and the
|
||||||
|
// mobile app couldn't read them. Route through the server-side SSO
|
||||||
|
// path instead, which has the mobile-aware /api/auth/sso/complete
|
||||||
|
// branch.
|
||||||
|
if (isMobileHandoff) {
|
||||||
|
await startServerSideSso();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setOauthLoading(true);
|
setOauthLoading(true);
|
||||||
|
|
||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
@@ -532,7 +617,7 @@ export default function LoginPage() {
|
|||||||
authUrl.searchParams.set("response_type", "code");
|
authUrl.searchParams.set("response_type", "code");
|
||||||
authUrl.searchParams.set("client_id", effectiveOauthClientId);
|
authUrl.searchParams.set("client_id", effectiveOauthClientId);
|
||||||
authUrl.searchParams.set("redirect_uri", redirectUri);
|
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||||
authUrl.searchParams.set("scope", OAUTH_SCOPES);
|
authUrl.searchParams.set("scope", oauthScopes || "openid email profile");
|
||||||
authUrl.searchParams.set("state", state);
|
authUrl.searchParams.set("state", state);
|
||||||
authUrl.searchParams.set("code_challenge", challenge);
|
authUrl.searchParams.set("code_challenge", challenge);
|
||||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||||
@@ -547,6 +632,16 @@ export default function LoginPage() {
|
|||||||
// when the admin hasn't configured a server list.
|
// when the admin hasn't configured a server list.
|
||||||
const effectiveServerUrl = selectedServer?.url
|
const effectiveServerUrl = selectedServer?.url
|
||||||
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
||||||
|
// Capture before login() so the isAuthenticated effect can build the
|
||||||
|
// deep-link fragment with values the user actually typed (formData may
|
||||||
|
// be cleared by the auth store on success).
|
||||||
|
if (isMobileHandoff) {
|
||||||
|
mobileHandoffPayloadRef.current = {
|
||||||
|
server_url: effectiveServerUrl,
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password,
|
||||||
|
};
|
||||||
|
}
|
||||||
const success = await login(
|
const success = await login(
|
||||||
effectiveServerUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
@@ -557,7 +652,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
saveUsername(formData.username);
|
saveUsername(formData.username);
|
||||||
|
if (isMobileHandoff) {
|
||||||
|
// The isAuthenticated effect handles the redirect; nothing else to
|
||||||
|
// do here. Don't push to / - that would race the deep link.
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push('/');
|
router.push('/');
|
||||||
|
} else if (isMobileHandoff) {
|
||||||
|
// Stale payload should never feed into a later retry's redirect.
|
||||||
|
mobileHandoffPayloadRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -572,7 +675,7 @@ export default function LoginPage() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -635,7 +738,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
<span className="flex-1 text-left">{option.label}</span>
|
<span className="flex-1 text-start">{option.label}</span>
|
||||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -650,7 +753,7 @@ export default function LoginPage() {
|
|||||||
<div className="px-8 pt-12 pb-4 text-center">
|
<div className="px-8 pt-12 pb-4 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||||
<img
|
<img
|
||||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
src={withBasePath(effLoginLogo)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="max-w-20 max-h-20 object-contain"
|
className="max-w-20 max-h-20 object-contain"
|
||||||
/>
|
/>
|
||||||
@@ -732,7 +835,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<VersionBadge />
|
{loginShowVersion && <VersionBadge />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -784,7 +887,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
<span className="flex-1 text-left">{option.label}</span>
|
<span className="flex-1 text-start">{option.label}</span>
|
||||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -798,19 +901,24 @@ export default function LoginPage() {
|
|||||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||||
{/* Header section with logo */}
|
{/* Header section with logo */}
|
||||||
<div className="px-8 pt-10 pb-6 text-center">
|
<div className="px-8 pt-10 pb-6 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
||||||
<img
|
<img
|
||||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
src={withBasePath(effLoginLogo)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="max-w-16 max-h-16 object-contain"
|
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
||||||
|
style={loginLogoStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
{loginShowHeading && (
|
||||||
{isAddAccountMode ? t("add_account_title") : appName}
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
</h1>
|
{isAddAccountMode ? t("add_account_title") : appName}
|
||||||
<p className="text-sm text-muted-foreground mt-1.5">
|
</h1>
|
||||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
)}
|
||||||
</p>
|
{loginShowSubtitle && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
|
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form section */}
|
{/* Form section */}
|
||||||
@@ -1038,7 +1146,7 @@ export default function LoginPage() {
|
|||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
placeholder={t("password_placeholder")}
|
placeholder={t("password_placeholder")}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
@@ -1059,8 +1167,12 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2FA toggle / field */}
|
{/* 2FA toggle / field. The manual toggle can be hidden via
|
||||||
|
LOGIN_SHOW_TOTP (loginShowTotp) for deployments whose mail
|
||||||
|
server has no per-account TOTP (auth delegated to an
|
||||||
|
external directory); server-required TOTP still shows. */}
|
||||||
{!showTotpField ? (
|
{!showTotpField ? (
|
||||||
|
loginShowTotp ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1072,6 +1184,7 @@ export default function LoginPage() {
|
|||||||
<Shield className="w-3.5 h-3.5" />
|
<Shield className="w-3.5 h-3.5" />
|
||||||
{t("totp_toggle")}
|
{t("totp_toggle")}
|
||||||
</button>
|
</button>
|
||||||
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
||||||
@@ -1158,9 +1271,9 @@ export default function LoginPage() {
|
|||||||
disabled={oauthLoading || isLoading}
|
disabled={oauthLoading || isLoading}
|
||||||
>
|
>
|
||||||
{oauthLoading ? (
|
{oauthLoading ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
) : (
|
) : (
|
||||||
<LogIn className="w-4 h-4 mr-2" />
|
<LogIn className="w-4 h-4 me-2" />
|
||||||
)}
|
)}
|
||||||
{t("sign_in_sso")}
|
{t("sign_in_sso")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1266,7 +1379,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<VersionBadge />
|
{loginShowVersion && <VersionBadge />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
|
import { EmbeddedContext } from "@/hooks/use-is-embedded";
|
||||||
|
import { PaneSizeContext } from "@/hooks/use-pane-size";
|
||||||
|
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
|
||||||
|
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
|
import MailPage from "@/app/(main)/[locale]/page";
|
||||||
|
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
|
||||||
|
import ContactsPage from "@/app/(main)/[locale]/contacts/page";
|
||||||
|
import FilesPage from "@/app/(main)/[locale]/files/page";
|
||||||
|
import SettingsPage from "@/app/(main)/[locale]/settings/page";
|
||||||
|
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
|
||||||
|
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
|
||||||
|
|
||||||
|
const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
|
||||||
|
mail: MailPage,
|
||||||
|
calendar: CalendarPage,
|
||||||
|
contacts: ContactsPage,
|
||||||
|
files: FilesPage,
|
||||||
|
settings: SettingsPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
type DropTarget = 'left' | 'right' | null;
|
||||||
|
|
||||||
|
function renderTabBody(tab: ProTab): React.ReactNode {
|
||||||
|
if (tab.kind === 'compose' && tab.composeData) {
|
||||||
|
return <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
|
||||||
|
}
|
||||||
|
if (tab.kind === 'email' && tab.emailData) {
|
||||||
|
return <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
|
||||||
|
}
|
||||||
|
const Component = APP_TAB_COMPONENTS[tab.kind];
|
||||||
|
return Component ? <Component /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneProps {
|
||||||
|
paneId: ProPaneId;
|
||||||
|
tabs: ProTab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
loadedTabIds: string[];
|
||||||
|
onPaneFocus: (paneId: ProPaneId) => void;
|
||||||
|
isFocused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
|
||||||
|
const paneRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// Measured pane width, published to children via PaneSizeContext so that
|
||||||
|
// useDeviceDetection / useIsMobile / etc. branch on pane width - not full
|
||||||
|
// viewport - and inner pages collapse to their mobile/tablet layouts when
|
||||||
|
// the pane is narrow.
|
||||||
|
const [paneWidth, setPaneWidth] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = paneRef.current;
|
||||||
|
if (!el || typeof ResizeObserver === "undefined") return;
|
||||||
|
const initialRect = el.getBoundingClientRect();
|
||||||
|
if (initialRect.width > 0) setPaneWidth(initialRect.width);
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) return;
|
||||||
|
const w = entry.contentRect.width;
|
||||||
|
setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={paneRef}
|
||||||
|
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
|
||||||
|
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
|
||||||
|
>
|
||||||
|
<PaneSizeContext.Provider value={paneWidth}>
|
||||||
|
{tabs
|
||||||
|
.filter((tab) => loadedTabIds.includes(tab.id))
|
||||||
|
.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTabId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
|
||||||
|
aria-hidden={!isActive}
|
||||||
|
>
|
||||||
|
{renderTabBody(tab)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</PaneSizeContext.Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProHome() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
|
||||||
|
|
||||||
|
const [initialCheckDone, setInitialCheckDone] = useState(
|
||||||
|
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client
|
||||||
|
);
|
||||||
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
|
const {
|
||||||
|
showAppsModal,
|
||||||
|
inlineApp,
|
||||||
|
loadedApps,
|
||||||
|
handleManageApps,
|
||||||
|
handleInlineApp,
|
||||||
|
closeInlineApp,
|
||||||
|
closeAppsModal,
|
||||||
|
} = useSidebarApps();
|
||||||
|
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const client = useAuthStore((s) => s.client);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const checkAuth = useAuthStore((s) => s.checkAuth);
|
||||||
|
const authLoading = useAuthStore((s) => s.isLoading);
|
||||||
|
const quota = useEmailStore((s) => s.quota);
|
||||||
|
const isPushConnected = useEmailStore((s) => s.isPushConnected);
|
||||||
|
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||||
|
|
||||||
|
const tabs = useProTabStore((s) => s.tabs);
|
||||||
|
const activeMainTabId = useProTabStore((s) => s.activeTabId);
|
||||||
|
const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId);
|
||||||
|
const splitOrientation = useProTabStore((s) => s.splitOrientation);
|
||||||
|
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||||
|
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||||
|
const openTab = useProTabStore((s) => s.openTab);
|
||||||
|
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
|
||||||
|
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
||||||
|
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
||||||
|
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
||||||
|
|
||||||
|
const [isTabDragging, setIsTabDragging] = useState(false);
|
||||||
|
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(null);
|
||||||
|
/** Whether the split pane visually renders before (true) or after (false) main. */
|
||||||
|
const [splitLeading, setSplitLeading] = useState(false);
|
||||||
|
|
||||||
|
// Auth bootstrap (mirrors standard page)
|
||||||
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkAuth().finally(() => {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
});
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialCheckDone || typeof window === "undefined") return;
|
||||||
|
// Pro is desktop-only, and only used when the user has explicitly
|
||||||
|
// enabled it. If either precondition stops holding, hand the user back
|
||||||
|
// to the standard shell.
|
||||||
|
if (isMobile || isTablet || !proInterface) {
|
||||||
|
window.location.replace(`${getPathPrefix()}/`);
|
||||||
|
}
|
||||||
|
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
||||||
|
|
||||||
|
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
|
||||||
|
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
|
||||||
|
|
||||||
|
const focusedActiveTab = useMemo(() => {
|
||||||
|
const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId;
|
||||||
|
return tabs.find((t) => t.id === id) ?? null;
|
||||||
|
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
|
||||||
|
|
||||||
|
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
|
||||||
|
openTab(itemId);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
|
||||||
|
focusedActiveTab && (
|
||||||
|
focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar'
|
||||||
|
|| focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files'
|
||||||
|
|| focusedActiveTab.kind === 'settings'
|
||||||
|
) ? focusedActiveTab.kind : null;
|
||||||
|
|
||||||
|
const isSplit = splitOrientation !== null && splitTabs.length > 0;
|
||||||
|
|
||||||
|
// ---- Body-level drop targets ----
|
||||||
|
|
||||||
|
const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
|
||||||
|
|
||||||
|
const computeDropTarget = (e: DragEvent<HTMLDivElement>): DropTarget => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const xFrac = (e.clientX - rect.left) / rect.width;
|
||||||
|
return xFrac < 0.5 ? 'left' : 'right';
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => {
|
||||||
|
if (!target || !isSplit) return null;
|
||||||
|
const leftIsSplit = splitLeading;
|
||||||
|
if (target === 'left') return leftIsSplit ? 'split' : 'main';
|
||||||
|
return leftIsSplit ? 'main' : 'split';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBodyDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!isProTabDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
const next = computeDropTarget(e);
|
||||||
|
if (next !== splitDropTarget) setSplitDropTarget(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBodyDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
const next = e.relatedTarget as Node | null;
|
||||||
|
if (next && e.currentTarget.contains(next)) return;
|
||||||
|
setSplitDropTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBodyDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!isProTabDrag(e)) return;
|
||||||
|
const target = computeDropTarget(e);
|
||||||
|
setSplitDropTarget(null);
|
||||||
|
setIsTabDragging(false);
|
||||||
|
if (!target) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||||
|
if (!draggedId) return;
|
||||||
|
|
||||||
|
if (isSplit) {
|
||||||
|
// Move tab to whichever pane occupies the dropped side.
|
||||||
|
const destPane = targetPaneFromDrop(target);
|
||||||
|
if (destPane) moveTabToPane(draggedId, destPane);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Create a new side-by-side split. `splitLeading` controls which side
|
||||||
|
// visually hosts the split pane.
|
||||||
|
moveTabToPane(draggedId, 'split', 'vertical');
|
||||||
|
setSplitLeading(target === 'left');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state (matches standard page exactly)
|
||||||
|
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDesktop) return null;
|
||||||
|
|
||||||
|
// Stable keys are essential: when the split collapses, the row's child
|
||||||
|
// list goes from [splitPane, divider, mainPane] (or the leading variant)
|
||||||
|
// to [mainPane]. Without keys, React would reuse the Pane instance at
|
||||||
|
// index 0 - repurposing the *split* pane's instance into the main pane,
|
||||||
|
// which strands the main pane's ResizeObserver/paneWidth on a now-
|
||||||
|
// unmounted DOM node and reparents the mail tab body (causing remount
|
||||||
|
// + stale "still-narrow" measurements after the split is closed).
|
||||||
|
const mainPane = (
|
||||||
|
<Pane
|
||||||
|
key="pane-main"
|
||||||
|
paneId="main"
|
||||||
|
tabs={mainTabs}
|
||||||
|
activeTabId={activeMainTabId}
|
||||||
|
loadedTabIds={loadedTabIds}
|
||||||
|
onPaneFocus={setFocusedPane}
|
||||||
|
isFocused={focusedPaneId === 'main'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const splitPane = isSplit ? (
|
||||||
|
<Pane
|
||||||
|
key="pane-split"
|
||||||
|
paneId="split"
|
||||||
|
tabs={splitTabs}
|
||||||
|
activeTabId={activeSplitTabId}
|
||||||
|
loadedTabIds={loadedTabIds}
|
||||||
|
onPaneFocus={setFocusedPane}
|
||||||
|
isFocused={focusedPaneId === 'split'}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const splitDivider = isSplit ? (
|
||||||
|
<div
|
||||||
|
key="pane-divider"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex-shrink-0 w-px bg-transparent"
|
||||||
|
style={{ borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Drop-zone overlay: a single half-body preview of where the dragged tab
|
||||||
|
// would land. The whole body is always a drop target (the entire surface
|
||||||
|
// maps to one of the four sides), so we only render the active side.
|
||||||
|
const dropZone = isTabDragging && splitDropTarget ? (
|
||||||
|
<DropZone side={splitDropTarget} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddedContext.Provider value={true}>
|
||||||
|
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Leftmost Navigation Rail - identical to the standard layout */}
|
||||||
|
<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}
|
||||||
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
|
onManageApps={handleManageApps}
|
||||||
|
onInlineApp={handleInlineApp}
|
||||||
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
onNavigate={handleRailNavigate}
|
||||||
|
activeItemId={railActiveItemId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inlineApp && (
|
||||||
|
<InlineAppView
|
||||||
|
apps={loadedApps}
|
||||||
|
activeAppId={inlineApp.id}
|
||||||
|
onClose={closeInlineApp}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!inlineApp && (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
|
||||||
|
{/* Single, unified tab bar above both panes. */}
|
||||||
|
<ProTabBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeMainTabId={activeMainTabId}
|
||||||
|
activeSplitTabId={activeSplitTabId}
|
||||||
|
onActivate={setActiveTab}
|
||||||
|
onClose={requestCloseTab}
|
||||||
|
onDragStateChange={setIsTabDragging}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panes container - accepts body drops for split/move. */}
|
||||||
|
<div
|
||||||
|
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
|
||||||
|
onDragOver={handleBodyDragOver}
|
||||||
|
onDragLeave={handleBodyDragLeave}
|
||||||
|
onDrop={handleBodyDrop}
|
||||||
|
>
|
||||||
|
{isSplit
|
||||||
|
? (splitLeading
|
||||||
|
? <>{splitPane}{splitDivider}{mainPane}</>
|
||||||
|
: <>{mainPane}{splitDivider}{splitPane}</>)
|
||||||
|
: mainPane}
|
||||||
|
|
||||||
|
{dropZone}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<KeyboardShortcutsModal
|
||||||
|
isOpen={showShortcutsModal}
|
||||||
|
onClose={() => setShowShortcutsModal(false)}
|
||||||
|
/>
|
||||||
|
{showAppsModal && (
|
||||||
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</EmbeddedContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropZone({ side }: { side: 'left' | 'right' }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute top-0 bottom-0 w-1/2 z-10",
|
||||||
|
"bg-primary/15 ring-2 ring-primary/40 ring-inset",
|
||||||
|
side === 'left' ? "left-0" : "right-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations, useMessages } from 'next-intl';
|
import { useTranslations, useMessages } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
Tags,
|
Tags,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
BookUser,
|
BookUser,
|
||||||
KeyRound,
|
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
Bell,
|
Bell,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
@@ -33,16 +32,23 @@ import {
|
|||||||
Languages,
|
Languages,
|
||||||
Info,
|
Info,
|
||||||
Bug,
|
Bug,
|
||||||
|
SwatchBook,
|
||||||
|
Download,
|
||||||
|
Sparkles,
|
||||||
|
Upload,
|
||||||
|
Share2,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||||
|
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
|
||||||
import { LayoutSettings } from '@/components/settings/layout-settings';
|
import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||||
import { ReadingSettings } from '@/components/settings/reading-settings';
|
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||||
import { ComposingSettings } from '@/components/settings/composing-settings';
|
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||||
|
import { SignatureSettings } from '@/components/settings/signature-settings';
|
||||||
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||||
import { AccountSettings } from '@/components/settings/account-settings';
|
import { AccountSettings } from '@/components/settings/account-settings';
|
||||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||||
@@ -58,23 +64,30 @@ import { FolderSettings } from '@/components/settings/folder-settings';
|
|||||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||||
|
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
|
||||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||||
|
import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings';
|
||||||
|
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||||
|
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||||
|
import { ImportSettings } from '@/components/settings/import-settings';
|
||||||
|
import { SharingSettings } from '@/components/settings/sharing-settings';
|
||||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { usePluginStore } from '@/stores/plugin-store';
|
import { usePluginStore } from '@/stores/plugin-store';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||||
|
import { useIsEmbedded } from '@/hooks/use-is-embedded';
|
||||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
@@ -88,14 +101,15 @@ type Tab =
|
|||||||
| 'layout'
|
| 'layout'
|
||||||
| 'reading'
|
| 'reading'
|
||||||
| 'composing'
|
| 'composing'
|
||||||
|
| 'downloads'
|
||||||
| 'identities'
|
| 'identities'
|
||||||
|
| 'signatures'
|
||||||
| 'vacation'
|
| 'vacation'
|
||||||
| 'filters'
|
| 'filters'
|
||||||
| 'templates'
|
| 'templates'
|
||||||
| 'folders'
|
| 'folders'
|
||||||
| 'keywords'
|
| 'keywords'
|
||||||
| 'security'
|
| 'security'
|
||||||
| 'encryption'
|
|
||||||
| 'content_senders'
|
| 'content_senders'
|
||||||
| 'calendar'
|
| 'calendar'
|
||||||
| 'contacts'
|
| 'contacts'
|
||||||
@@ -105,12 +119,21 @@ type Tab =
|
|||||||
| 'about_data'
|
| 'about_data'
|
||||||
| 'themes'
|
| 'themes'
|
||||||
| 'plugins'
|
| 'plugins'
|
||||||
|
| 'import'
|
||||||
|
| 'sharing'
|
||||||
|
| 'ai_assistant'
|
||||||
| 'debug';
|
| 'debug';
|
||||||
|
|
||||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||||
|
|
||||||
|
// A plugin that exposes a `settings-section` slot gets its own first-class
|
||||||
|
// Settings entry, keyed `plugin:<id>`, so its UI (e.g. S/MIME key import) is
|
||||||
|
// discoverable as a menu point rather than buried inside another panel.
|
||||||
|
type PluginTabId = `plugin:${string}`;
|
||||||
|
type SettingsTabId = Tab | PluginTabId;
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
id: Tab;
|
id: SettingsTabId;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
group: TabGroup;
|
group: TabGroup;
|
||||||
@@ -124,14 +147,15 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
layout: LayoutGrid,
|
layout: LayoutGrid,
|
||||||
reading: BookOpen,
|
reading: BookOpen,
|
||||||
composing: PenLine,
|
composing: PenLine,
|
||||||
|
downloads: Download,
|
||||||
identities: UserPen,
|
identities: UserPen,
|
||||||
|
signatures: PenLine,
|
||||||
vacation: PalmtreeIcon,
|
vacation: PalmtreeIcon,
|
||||||
filters: Filter,
|
filters: Filter,
|
||||||
templates: FileText,
|
templates: FileText,
|
||||||
folders: FolderOpen,
|
folders: FolderOpen,
|
||||||
keywords: Tags,
|
keywords: Tags,
|
||||||
security: Shield,
|
security: Shield,
|
||||||
encryption: KeyRound,
|
|
||||||
content_senders: EyeOff,
|
content_senders: EyeOff,
|
||||||
calendar: Calendar,
|
calendar: Calendar,
|
||||||
contacts: BookUser,
|
contacts: BookUser,
|
||||||
@@ -139,8 +163,11 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
protocol_handlers: LinkIcon,
|
protocol_handlers: LinkIcon,
|
||||||
sidebar_apps: PanelLeftClose,
|
sidebar_apps: PanelLeftClose,
|
||||||
about_data: Info,
|
about_data: Info,
|
||||||
themes: Palette,
|
themes: SwatchBook,
|
||||||
plugins: Puzzle,
|
plugins: Puzzle,
|
||||||
|
import: Upload,
|
||||||
|
sharing: Share2,
|
||||||
|
ai_assistant: Sparkles,
|
||||||
debug: Bug,
|
debug: Bug,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,6 +186,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.account.email',
|
'settings.account.email',
|
||||||
'settings.account.server',
|
'settings.account.server',
|
||||||
'settings.account.storage',
|
'settings.account.storage',
|
||||||
|
'settings.account.accounts',
|
||||||
],
|
],
|
||||||
language: ['settings.appearance.language'],
|
language: ['settings.appearance.language'],
|
||||||
notifications: ['settings.notifications'],
|
notifications: ['settings.notifications'],
|
||||||
@@ -174,6 +202,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.appearance.hide_account_switcher',
|
'settings.appearance.hide_account_switcher',
|
||||||
'settings.appearance.show_rail_account_list',
|
'settings.appearance.show_rail_account_list',
|
||||||
'settings.appearance.unified_mailbox',
|
'settings.appearance.unified_mailbox',
|
||||||
|
'settings.appearance.all_mail',
|
||||||
'settings.appearance.colorful_sidebar_icons',
|
'settings.appearance.colorful_sidebar_icons',
|
||||||
'settings.email_behavior.mail_layout',
|
'settings.email_behavior.mail_layout',
|
||||||
],
|
],
|
||||||
@@ -190,23 +219,24 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.email_behavior.hover_actions',
|
'settings.email_behavior.hover_actions',
|
||||||
'settings.email_behavior.permanently_delete_junk',
|
'settings.email_behavior.permanently_delete_junk',
|
||||||
'settings.email_behavior.show_preview',
|
'settings.email_behavior.show_preview',
|
||||||
'settings.email_behavior.plain_text_mode',
|
|
||||||
],
|
],
|
||||||
composing: [
|
composing: [
|
||||||
'settings.email_behavior.attachment_reminder',
|
'settings.email_behavior.attachment_reminder',
|
||||||
'settings.email_behavior.auto_select_reply_identity',
|
'settings.email_behavior.auto_select_reply_identity',
|
||||||
|
'settings.email_behavior.plain_text_mode',
|
||||||
'settings.email_behavior.default_mail_program',
|
'settings.email_behavior.default_mail_program',
|
||||||
'settings.email_behavior.signature_position',
|
'settings.email_behavior.signature_position',
|
||||||
'settings.email_behavior.sub_address_delimiter',
|
'settings.email_behavior.sub_address_delimiter',
|
||||||
],
|
],
|
||||||
|
downloads: ['settings.downloads'],
|
||||||
identities: ['settings.identities'],
|
identities: ['settings.identities'],
|
||||||
|
signatures: ['signatures'],
|
||||||
vacation: ['settings.vacation'],
|
vacation: ['settings.vacation'],
|
||||||
filters: ['settings.filters'],
|
filters: ['settings.filters'],
|
||||||
templates: ['settings.templates'],
|
templates: ['settings.templates'],
|
||||||
folders: ['settings.folders'],
|
folders: ['settings.folders'],
|
||||||
keywords: ['settings.keywords'],
|
keywords: ['settings.keywords'],
|
||||||
security: ['settings.security'],
|
security: ['settings.security'],
|
||||||
encryption: ['smime'],
|
|
||||||
content_senders: [
|
content_senders: [
|
||||||
'settings.email_behavior.always_light_mode',
|
'settings.email_behavior.always_light_mode',
|
||||||
'settings.email_behavior.external_content',
|
'settings.email_behavior.external_content',
|
||||||
@@ -220,27 +250,31 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
about_data: ['settings.advanced'],
|
about_data: ['settings.advanced'],
|
||||||
themes: [],
|
themes: [],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
ai_assistant: [],
|
||||||
|
import: ['settings.importer'],
|
||||||
|
sharing: ['sharing'],
|
||||||
debug: ['settings.advanced'],
|
debug: ['settings.advanced'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extra English keywords per tab so common search terms hit even when the
|
// Extra English keywords per tab so common search terms hit even when the
|
||||||
// translation doesn't contain the literal word.
|
// translation doesn't contain the literal word.
|
||||||
const tabKeywords: Record<Tab, string> = {
|
const tabKeywords: Record<Tab, string> = {
|
||||||
account: 'profile email password user signin signout',
|
account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account',
|
||||||
language: 'locale region timezone date time format',
|
language: 'locale region timezone date time format',
|
||||||
notifications: 'sound alert push badge',
|
notifications: 'sound alert push badge',
|
||||||
appearance: 'theme dark light font size accent color animation density',
|
appearance: 'theme dark light font size accent color animation density',
|
||||||
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
||||||
reading: 'mark read preview thread conversation archive delete attachment open',
|
reading: 'mark read preview thread conversation archive delete attachment open',
|
||||||
composing: 'editor signature plain text reply forward draft compose',
|
composing: 'editor signature plain text reply forward draft compose',
|
||||||
|
downloads: 'download filename template eml attachment save export',
|
||||||
identities: 'from address signature email',
|
identities: 'from address signature email',
|
||||||
|
signatures: 'signature rich text html editor',
|
||||||
vacation: 'auto reply away out of office holiday responder',
|
vacation: 'auto reply away out of office holiday responder',
|
||||||
filters: 'sieve rules block junk forward',
|
filters: 'sieve rules block junk forward',
|
||||||
templates: 'snippet quick reply',
|
templates: 'snippet quick reply',
|
||||||
folders: 'mailbox subscribe',
|
folders: 'mailbox subscribe',
|
||||||
keywords: 'tags labels colors',
|
keywords: 'tags labels colors',
|
||||||
security: 'password 2fa two-factor passkey app password mfa',
|
security: 'password 2fa two-factor passkey app password mfa',
|
||||||
encryption: 's/mime smime certificate pgp gpg',
|
|
||||||
content_senders: 'block sender remote images privacy tracking',
|
content_senders: 'block sender remote images privacy tracking',
|
||||||
calendar: 'event schedule appointment meeting timezone',
|
calendar: 'event schedule appointment meeting timezone',
|
||||||
contacts: 'address book contact',
|
contacts: 'address book contact',
|
||||||
@@ -250,6 +284,9 @@ const tabKeywords: Record<Tab, string> = {
|
|||||||
about_data: 'export import storage quota privacy backup',
|
about_data: 'export import storage quota privacy backup',
|
||||||
themes: 'custom theme css skin appearance',
|
themes: 'custom theme css skin appearance',
|
||||||
plugins: 'extensions addons',
|
plugins: 'extensions addons',
|
||||||
|
ai_assistant: 'assistant ask model llm ollama chatbot',
|
||||||
|
import: 'import email eml zip tgz mbox csv vcard contacts',
|
||||||
|
sharing: 'share shared folder calendar address book permission',
|
||||||
debug: 'logs developer console diagnostic',
|
debug: 'logs developer console diagnostic',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -324,8 +361,16 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
|||||||
advanced: 'about_data',
|
advanced: 'about_data',
|
||||||
};
|
};
|
||||||
|
|
||||||
function readPersistedTab(): Tab {
|
function readPersistedTab(): SettingsTabId {
|
||||||
try {
|
try {
|
||||||
|
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||||
|
// Used only as the initial tab and intentionally NOT written to
|
||||||
|
// 'settings-active-tab', so a gear click never becomes the persisted
|
||||||
|
// default that the regular Settings button lands on. Cleared on mount.
|
||||||
|
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
||||||
|
if (deepLink) {
|
||||||
|
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId;
|
||||||
|
}
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
if (!saved) return 'appearance';
|
if (!saved) return 'appearance';
|
||||||
if (saved in LEGACY_TAB_MAP) {
|
if (saved in LEGACY_TAB_MAP) {
|
||||||
@@ -333,7 +378,7 @@ function readPersistedTab(): Tab {
|
|||||||
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||||
return migrated;
|
return migrated;
|
||||||
}
|
}
|
||||||
return saved as Tab;
|
return saved as SettingsTabId;
|
||||||
} catch {
|
} catch {
|
||||||
return 'appearance';
|
return 'appearance';
|
||||||
}
|
}
|
||||||
@@ -345,20 +390,41 @@ export default function SettingsPage() {
|
|||||||
const tSidebar = useTranslations('sidebar');
|
const tSidebar = useTranslations('sidebar');
|
||||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
const [activeTab, setActiveTab] = useState<SettingsTabId>(readPersistedTab);
|
||||||
|
// Active plugins that expose a `settings-section` slot — each becomes its own
|
||||||
|
// Settings menu entry. Referentially stable per registry mutation, so it is
|
||||||
|
// safe to feed useSyncExternalStore directly.
|
||||||
|
const pluginSettingsOffers = useSyncExternalStore(
|
||||||
|
pluginRegistrySubscribe,
|
||||||
|
() => pluginOffersForSlot('settings-section'),
|
||||||
|
() => pluginOffersForSlot('settings-section'),
|
||||||
|
);
|
||||||
|
// Consume the one-shot deep-link key so a section gear only steers this one
|
||||||
|
// open, never the persisted default for future Settings-button clicks.
|
||||||
|
useEffect(() => {
|
||||||
|
try { sessionStorage.removeItem('settings-deep-link-tab'); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
const messages = useMessages() as Record<string, unknown>;
|
const messages = useMessages() as Record<string, unknown>;
|
||||||
const installedPlugins = usePluginStore((s) => s.plugins);
|
const installedPlugins = usePluginStore((s) => s.plugins);
|
||||||
const installedThemes = useThemeStore((s) => s.installedThemes);
|
const installedThemes = useThemeStore((s) => s.installedThemes);
|
||||||
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
||||||
|
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||||
|
|
||||||
|
// When set, the settings panel is scoped to a shared/group account: a reduced
|
||||||
|
// tab list and a "Managing: <name>" header. null = the user's own account.
|
||||||
|
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||||
|
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
|
||||||
|
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
|
||||||
|
|
||||||
// Build a per-tab haystack for fulltext search and a list of sub-results
|
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||||
// (individual settings) per tab. Sub-results come from translation entries
|
// (individual settings) per tab. Sub-results come from translation entries
|
||||||
@@ -463,6 +529,10 @@ export default function SettingsPage() {
|
|||||||
return () => window.removeEventListener('settings-tab-change', handler);
|
return () => window.removeEventListener('settings-tab-change', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Leaving the settings panel drops any shared-account scope so it never
|
||||||
|
// leaks into the next visit or another session.
|
||||||
|
useEffect(() => () => clearManagedAccount(), [clearManagedAccount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
@@ -566,46 +636,74 @@ export default function SettingsPage() {
|
|||||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||||
|
{ id: 'sharing', label: t('tabs.sharing'), icon: tabIcons.sharing, group: 'general' },
|
||||||
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||||
|
|
||||||
// Appearance
|
// Appearance
|
||||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||||
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||||
|
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []),
|
||||||
|
|
||||||
// Mail
|
// Mail
|
||||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||||
|
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
|
||||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||||
|
{ id: 'signatures', label: t('tabs.signatures'), icon: tabIcons.signatures, group: 'mail' },
|
||||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||||
|
{ id: 'import', label: t('tabs.import'), icon: tabIcons.import, group: 'mail' },
|
||||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
|
||||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||||
|
|
||||||
// Apps
|
// Apps
|
||||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||||
|
// Plugin-contributed settings pages: one entry per active plugin that
|
||||||
|
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
|
||||||
|
...pluginSettingsOffers.map((offer): TabDef => ({
|
||||||
|
id: `plugin:${offer.pluginId}` as PluginTabId,
|
||||||
|
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
|
||||||
|
icon: Puzzle,
|
||||||
|
group: 'apps',
|
||||||
|
})),
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
|
|
||||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
||||||
|
...(isFeatureEnabled('aiAssistantEnabled') ? [{ id: 'ai_assistant' as Tab, label: 'AI Assistant', icon: tabIcons.ai_assistant, group: 'advanced' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// In scoped (shared-account) mode, restrict to the account-relevant tabs the
|
||||||
|
// account actually advertises. Folders is intentionally excluded (mailbox CRUD
|
||||||
|
// is hardwired to the active account). Gated on both the per-account
|
||||||
|
// capability and the session-level support/feature flags.
|
||||||
|
const scopedTabIds: Tab[] = managedAccount
|
||||||
|
? ([
|
||||||
|
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
|
||||||
|
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
|
||||||
|
managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null,
|
||||||
|
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
|
||||||
|
].filter(Boolean) as Tab[])
|
||||||
|
: [];
|
||||||
|
const visibleTabs = managedAccountId
|
||||||
|
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
|
||||||
|
: tabs;
|
||||||
|
|
||||||
// Group tabs by category
|
// Group tabs by category
|
||||||
const groupedTabs = tabGroupOrder
|
const groupedTabs = tabGroupOrder
|
||||||
.map((group) => ({
|
.map((group) => ({
|
||||||
group,
|
group,
|
||||||
label: t(`tab_groups.${group}`),
|
label: t(`tab_groups.${group}`),
|
||||||
items: tabs.filter((tab) => tab.group === group),
|
items: visibleTabs.filter((tab) => tab.group === group),
|
||||||
}))
|
}))
|
||||||
.filter((g) => g.items.length > 0);
|
.filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
@@ -613,12 +711,12 @@ export default function SettingsPage() {
|
|||||||
const matchesQuery = (tab: TabDef) => {
|
const matchesQuery = (tab: TabDef) => {
|
||||||
if (!trimmedQuery) return true;
|
if (!trimmedQuery) return true;
|
||||||
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
|
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
|
||||||
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
|
return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const subResultsForTab = (tabId: Tab): SubResult[] => {
|
const subResultsForTab = (tabId: SettingsTabId): SubResult[] => {
|
||||||
if (!trimmedQuery) return [];
|
if (!trimmedQuery) return [];
|
||||||
const list = tabSubResults[tabId] ?? [];
|
const list = tabSubResults[tabId as Tab] ?? [];
|
||||||
return list
|
return list
|
||||||
.filter((r) =>
|
.filter((r) =>
|
||||||
r.label.toLowerCase().includes(trimmedQuery) ||
|
r.label.toLowerCase().includes(trimmedQuery) ||
|
||||||
@@ -633,11 +731,15 @@ export default function SettingsPage() {
|
|||||||
.filter((g) => g.items.length > 0)
|
.filter((g) => g.items.length > 0)
|
||||||
: groupedTabs;
|
: groupedTabs;
|
||||||
|
|
||||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
// If active tab is not in the visible list (e.g., feature disabled, or scoped
|
||||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
// otherwise the usual 'appearance' default.
|
||||||
|
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||||
|
const effectiveActiveTab: SettingsTabId = isActiveVisible
|
||||||
|
? activeTab
|
||||||
|
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
||||||
|
|
||||||
const handleTabSelect = (tabId: Tab) => {
|
const handleTabSelect = (tabId: SettingsTabId) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
@@ -645,15 +747,31 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
|
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
|
||||||
handleTabSelect(tabId);
|
handleTabSelect(tabId);
|
||||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||||
|
|
||||||
const renderTabContent = () => (
|
const renderTabContent = () => (
|
||||||
<>
|
<>
|
||||||
|
{managedAccountId && managedAccount && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clearManagedAccount();
|
||||||
|
handleTabSelect('account');
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
|
||||||
|
<span className="ms-auto text-sm font-medium truncate">
|
||||||
|
{t('scoped.managing', { name: managedAccount.name })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{effectiveActiveTab === 'account' && <AccountSettings />}
|
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||||
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||||
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||||
@@ -661,24 +779,43 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||||
|
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||||
|
{effectiveActiveTab === 'signatures' && <SignatureSettings />}
|
||||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||||
|
{effectiveActiveTab === 'import' && <ImportSettings />}
|
||||||
|
{effectiveActiveTab === 'sharing' && <SharingSettings />}
|
||||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
|
||||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
{effectiveActiveTab === 'calendar' && (
|
||||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
managedAccountId
|
||||||
|
? <CalendarManagementSettings />
|
||||||
|
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
|
||||||
|
)}
|
||||||
|
{effectiveActiveTab === 'contacts' && (
|
||||||
|
managedAccountId
|
||||||
|
? <AddressBookManagementSettings />
|
||||||
|
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
|
||||||
|
)}
|
||||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
|
{effectiveActiveTab === 'ai_assistant' && <AiAssistantSettings />}
|
||||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
|
{effectiveActiveTab.startsWith('plugin:') && (
|
||||||
|
<PluginIframeSlot
|
||||||
|
key={effectiveActiveTab}
|
||||||
|
pluginId={effectiveActiveTab.slice('plugin:'.length)}
|
||||||
|
slot="settings-section"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -686,7 +823,8 @@ export default function SettingsPage() {
|
|||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
if (mobileShowContent) {
|
if (mobileShowContent) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-background">
|
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
|
<AppTopBannerSlot />
|
||||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -703,20 +841,23 @@ export default function SettingsPage() {
|
|||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavigationRail
|
{!isEmbedded && (
|
||||||
orientation="horizontal"
|
<NavigationRail
|
||||||
onManageApps={handleManageApps}
|
orientation="horizontal"
|
||||||
onInlineApp={handleInlineApp}
|
onManageApps={handleManageApps}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
activeAppId={inlineApp?.id ?? null}
|
onCloseInlineApp={closeInlineApp}
|
||||||
/>
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-background">
|
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
|
<AppTopBannerSlot />
|
||||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -741,7 +882,7 @@ export default function SettingsPage() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('search_placeholder')}
|
placeholder={t('search_placeholder')}
|
||||||
className="pl-9 pr-9 h-10"
|
className="ps-9 pe-9 h-10"
|
||||||
aria-label={t('search_placeholder')}
|
aria-label={t('search_placeholder')}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
@@ -789,7 +930,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
key={`${tab.id}:${sub.label}`}
|
key={`${tab.id}:${sub.label}`}
|
||||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||||
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
|
className="w-full flex items-center ps-12 pe-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
|
||||||
>
|
>
|
||||||
<span className="truncate">{sub.label}</span>
|
<span className="truncate">{sub.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -812,13 +953,15 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavigationRail
|
{!isEmbedded && (
|
||||||
orientation="horizontal"
|
<NavigationRail
|
||||||
onManageApps={handleManageApps}
|
orientation="horizontal"
|
||||||
onInlineApp={handleInlineApp}
|
onManageApps={handleManageApps}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
activeAppId={inlineApp?.id ?? null}
|
onCloseInlineApp={closeInlineApp}
|
||||||
/>
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -826,19 +969,23 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
// Desktop layout
|
// Desktop layout
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background">
|
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<AppTopBannerSlot />
|
||||||
<NavigationRail
|
<div className="flex flex-1 min-h-0">
|
||||||
collapsed
|
{!isEmbedded && (
|
||||||
quota={quota}
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
isPushConnected={isPushConnected}
|
<NavigationRail
|
||||||
onLogout={logout}
|
collapsed
|
||||||
onManageApps={handleManageApps}
|
quota={quota}
|
||||||
onInlineApp={handleInlineApp}
|
isPushConnected={isPushConnected}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onLogout={logout}
|
||||||
activeAppId={inlineApp?.id ?? null}
|
onManageApps={handleManageApps}
|
||||||
/>
|
onInlineApp={handleInlineApp}
|
||||||
</div>
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{inlineApp && (
|
{inlineApp && (
|
||||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||||
@@ -847,22 +994,24 @@ export default function SettingsPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary flex flex-col",
|
"border-e border-border bg-secondary flex flex-col",
|
||||||
!isResizing && "transition-[width] duration-300"
|
!isResizing && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${settingsSidebarWidth}px` }}
|
style={{ width: `${settingsSidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
<div className="p-4 border-b border-border">
|
{!proInterface && (
|
||||||
<Button
|
<div className="p-4 border-b border-border">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => router.push('/')}
|
size="sm"
|
||||||
className="w-full justify-start"
|
onClick={() => router.push('/')}
|
||||||
>
|
className="w-full justify-start"
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
>
|
||||||
{t('back_to_mail')}
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
</Button>
|
{t('back_to_mail')}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||||
<div className="px-3 pt-1 pb-1">
|
<div className="px-3 pt-1 pb-1">
|
||||||
@@ -873,7 +1022,7 @@ export default function SettingsPage() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('search_placeholder')}
|
placeholder={t('search_placeholder')}
|
||||||
className="pl-8 pr-8 h-9 text-sm"
|
className="ps-8 pe-8 h-9 text-sm"
|
||||||
aria-label={t('search_placeholder')}
|
aria-label={t('search_placeholder')}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
@@ -908,9 +1057,9 @@ export default function SettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div key={tab.id}>
|
<div key={tab.id}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => handleTabSelect(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
effectiveActiveTab === tab.id
|
effectiveActiveTab === tab.id
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -926,7 +1075,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
key={`${tab.id}:${sub.label}`}
|
key={`${tab.id}:${sub.label}`}
|
||||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||||
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
className="w-full text-start ps-9 pe-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<span className="truncate block">{sub.label}</span>
|
<span className="truncate block">{sub.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -958,6 +1107,7 @@ export default function SettingsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+7
-20
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
|
|||||||
|
|
||||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||||
|
const lastEmittedRef = useRef(value);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
if (value === lastEmittedRef.current) return;
|
||||||
// initial load). Skip when drafts already represent the same array to avoid
|
setDrafts(value.map(entryToDraft))
|
||||||
// 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]);
|
}, [value]);
|
||||||
|
|
||||||
function commit(next: RowDraft[]) {
|
function commit(next: RowDraft[]) {
|
||||||
setDrafts(next);
|
setDrafts(next);
|
||||||
const entries: JmapServerEntry[] = [];
|
const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
|
||||||
for (const d of next) {
|
lastEmittedRef.current = entries;
|
||||||
const e = draftToEntry(d);
|
|
||||||
if (e) entries.push(e);
|
|
||||||
}
|
|
||||||
onChange(entries);
|
onChange(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
|||||||
Per-server OAuth (optional, overrides global)
|
Per-server OAuth (optional, overrides global)
|
||||||
</button>
|
</button>
|
||||||
{d.oauthExpanded && (
|
{d.oauthExpanded && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||||
<input
|
<input
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, X, ArrowRight } from 'lucide-react';
|
||||||
|
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types';
|
||||||
|
import { DEFAULT_AI_CONSOLE_CONFIG } from '@/lib/ai/types';
|
||||||
|
import type { AiEntitlementState, MeteringEntry } from '@/lib/ai/entitlement';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { useAdminTabStore } from '@/stores/admin-tab-store';
|
||||||
|
|
||||||
|
type EntitlementResponse = AiEntitlementState & { recentUsage: MeteringEntry[] };
|
||||||
|
|
||||||
|
const CLASS_INFO: Record<AiClass, { name: string; desc: string }> = {
|
||||||
|
local: { name: 'Local', desc: "Ollama on the user's own machine. Free, unmetered, never reaches this server." },
|
||||||
|
server: { name: 'Server', desc: 'VNC-hosted. Entitlement-enforced, seat + usage tracked below.' },
|
||||||
|
opencode: { name: 'OpenCode', desc: 'A locally-running OpenCode agent server. Holds its own provider credentials; nothing metered here.' },
|
||||||
|
public: { name: 'Public (BYOK)', desc: "User's own API key, direct from their browser to the provider." },
|
||||||
|
};
|
||||||
|
|
||||||
|
function AllowlistEditor({
|
||||||
|
values, onChange, placeholder,
|
||||||
|
}: { values: string[] | null; onChange: (next: string[] | null) => void; placeholder: string }) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const restricted = values !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-3.5 px-4 pt-2.5 pb-0.5 text-xs">
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-muted-foreground">
|
||||||
|
<input type="radio" checked={!restricted} onChange={() => onChange(null)} />
|
||||||
|
Unrestricted (current)
|
||||||
|
</label>
|
||||||
|
<label className={`flex items-center gap-1.5 cursor-pointer ${restricted ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>
|
||||||
|
<input type="radio" checked={restricted} onChange={() => onChange(values ?? [])} />
|
||||||
|
Restrict to selected
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{restricted && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1.5 px-4 pt-2.5">
|
||||||
|
{(values ?? []).map((v) => (
|
||||||
|
<span key={v} className="inline-flex items-center gap-1.5 bg-muted border border-border rounded-full py-1 pl-3 pr-1.5 text-xs">
|
||||||
|
{v}
|
||||||
|
<button onClick={() => onChange((values ?? []).filter((x) => x !== v))} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 px-4 py-3">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 h-8 rounded border border-input bg-background px-2.5 text-xs"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && draft.trim()) {
|
||||||
|
onChange([...(values ?? []), draft.trim()]);
|
||||||
|
setDraft('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (draft.trim()) { onChange([...(values ?? []), draft.trim()]); setDraft(''); } }}
|
||||||
|
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiPolicyTab() {
|
||||||
|
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||||
|
const [config, setConfig] = useState<AiConsoleConfig>({ ...DEFAULT_AI_CONSOLE_CONFIG });
|
||||||
|
const [entitlement, setEntitlement] = useState<EntitlementResponse | null>(null);
|
||||||
|
const [serverModels, setServerModels] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [policyRes, entitlementRes, modelsRes] = await Promise.all([
|
||||||
|
apiFetch('/api/admin/ai/policy'),
|
||||||
|
apiFetch('/api/admin/ai/entitlement'),
|
||||||
|
apiFetch('/api/ai/server/models').catch(() => null),
|
||||||
|
]);
|
||||||
|
if (policyRes.ok) setConfig(await policyRes.json());
|
||||||
|
if (entitlementRes.ok) setEntitlement(await entitlementRes.json());
|
||||||
|
if (modelsRes?.ok) {
|
||||||
|
const data = await modelsRes.json();
|
||||||
|
setServerModels(data.models ?? []);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(patch: Partial<AiConsoleConfig>) {
|
||||||
|
setConfig((prev) => ({ ...prev, ...patch }));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleClass(cls: AiClass) {
|
||||||
|
const current = config.classesEnabled[cls] !== false;
|
||||||
|
update({ classesEnabled: { ...config.classesEnabled, [cls]: !current } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
const res = await apiFetch('/api/admin/ai/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(await res.json());
|
||||||
|
setDirty(false);
|
||||||
|
setMessage({ type: 'success', text: 'Saved.' });
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSeatTotal(total: number) {
|
||||||
|
const res = await apiFetch('/api/admin/ai/entitlement', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ seatsTotal: total }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setEntitlement((prev) => (prev ? { ...prev, ...data } : prev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSeat(username: string) {
|
||||||
|
const res = await apiFetch('/api/admin/ai/entitlement', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ revokeUsername: username }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setEntitlement((prev) => (prev ? { ...prev, ...data } : prev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverInfraAvailable = serverModels.length > 0 || entitlement !== null;
|
||||||
|
const usageToday = (entitlement?.recentUsage ?? []).filter((u) => u.timestamp.slice(0, 10) === new Date().toISOString().slice(0, 10));
|
||||||
|
const tokensToday = usageToday.reduce((sum, u) => sum + u.promptTokens + u.completionTokens, 0);
|
||||||
|
const avgLatency = usageToday.length ? Math.round(usageToday.reduce((sum, u) => sum + u.latencyMs, 0) / usageToday.length) : 0;
|
||||||
|
|
||||||
|
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">AI</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Provider classes, allow-lists, seats, usage, and BYOK consent for the AI Assistant.</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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={() => setActiveTab('policy')}
|
||||||
|
className="w-full flex items-center gap-2 text-xs text-muted-foreground bg-muted border border-border rounded-md px-3.5 py-2.5 hover:bg-muted/70 transition-colors text-left">
|
||||||
|
<span>The master AI Assistant on/off switch lives in</span>
|
||||||
|
<span className="text-primary font-medium inline-flex items-center gap-1">Policy → Feature Gates <ArrowRight className="w-3 h-3" /></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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">Provider classes</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Which of the three AI classes users can reach at all.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 p-4">
|
||||||
|
{(['local', 'server', 'opencode', 'public'] as AiClass[]).map((cls) => {
|
||||||
|
const enabled = config.classesEnabled[cls] !== false;
|
||||||
|
const disabledByInfra = cls === 'server' && !serverInfraAvailable;
|
||||||
|
return (
|
||||||
|
<div key={cls} className={`border border-border rounded-md p-3.5 ${disabledByInfra ? 'opacity-55' : ''}`}>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-sm font-semibold">{CLASS_INFO[cls].name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => !disabledByInfra && toggleClass(cls)}
|
||||||
|
disabled={disabledByInfra}
|
||||||
|
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled && !disabledByInfra ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'} ${disabledByInfra ? 'cursor-not-allowed' : ''}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled && !disabledByInfra ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{CLASS_INFO[cls].desc}</p>
|
||||||
|
{disabledByInfra && <p className="text-xs text-amber-600 dark:text-amber-400 mt-1.5">Not configured (AI_SERVER_BASE_URL unset)</p>}
|
||||||
|
</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">Server — model allow-list</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Restrict which Ollama models users may select for the Server class. Also enforced on every chat call, not just the picker.</p>
|
||||||
|
</div>
|
||||||
|
<AllowlistEditor
|
||||||
|
values={config.serverModelAllowlist}
|
||||||
|
onChange={(v) => update({ serverModelAllowlist: v })}
|
||||||
|
placeholder={serverModels.length ? `e.g. ${serverModels[0]}` : 'e.g. qwen2.5:32b'}
|
||||||
|
/>
|
||||||
|
</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">Public (BYOK) — provider allow-list</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Restrict which base URLs users may point a bring-your-own-key profile at. Checked client-side at save time — advisory, not a network boundary.</p>
|
||||||
|
</div>
|
||||||
|
<AllowlistEditor
|
||||||
|
values={config.publicProviderAllowlist}
|
||||||
|
onChange={(v) => update({ publicProviderAllowlist: v })}
|
||||||
|
placeholder="e.g. https://api.openai.com"
|
||||||
|
/>
|
||||||
|
</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">Entitlement & seats</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Server class only. First successful use auto-assigns a seat.</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-3 border-b border-border">
|
||||||
|
<span className="text-sm flex-1">Seats licensed</span>
|
||||||
|
<input
|
||||||
|
type="number" min={0}
|
||||||
|
value={entitlement?.seatsTotal ?? 0}
|
||||||
|
onChange={(e) => setSeatTotal(Math.max(0, Number.parseInt(e.target.value, 10) || 0))}
|
||||||
|
className="w-20 h-8 rounded border border-input bg-background px-2 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">{entitlement?.assignedTo.length ?? 0} of {entitlement?.seatsTotal ?? 0} assigned</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{(entitlement?.assignedTo ?? []).length === 0 && (
|
||||||
|
<div className="px-4 py-3 text-xs text-muted-foreground">No seats assigned yet.</div>
|
||||||
|
)}
|
||||||
|
{(entitlement?.assignedTo ?? []).map((username) => (
|
||||||
|
<div key={username} className="px-4 py-2.5 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm">{username}</span>
|
||||||
|
<button onClick={() => revokeSeat(username)}
|
||||||
|
className="text-xs font-medium text-destructive border border-border rounded px-2.5 py-1 hover:bg-destructive/10">
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</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">Usage</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Last 200 metered calls. Read-only.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 px-4 py-3 border-b border-border flex-wrap">
|
||||||
|
<div><span className="text-lg font-semibold tabular-nums block">{usageToday.length}</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Calls today</span></div>
|
||||||
|
<div><span className="text-lg font-semibold tabular-nums block">{tokensToday.toLocaleString()}</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Tokens today</span></div>
|
||||||
|
<div><span className="text-lg font-semibold tabular-nums block">{avgLatency}ms</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Avg latency</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground uppercase text-[10px] tracking-wide">
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Time</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">User</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Model</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Prompt tok</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Compl. tok</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Latency</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{(entitlement?.recentUsage ?? []).length === 0 && (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-3 text-muted-foreground">No usage recorded yet.</td></tr>
|
||||||
|
)}
|
||||||
|
{[...(entitlement?.recentUsage ?? [])].reverse().slice(0, 50).map((u, i) => (
|
||||||
|
<tr key={i} className="tabular-nums">
|
||||||
|
<td className="px-4 py-2">{new Date(u.timestamp).toLocaleTimeString()}</td>
|
||||||
|
<td className="px-4 py-2">{u.username}</td>
|
||||||
|
<td className="px-4 py-2">{u.model}</td>
|
||||||
|
<td className="px-4 py-2">{u.promptTokens}</td>
|
||||||
|
<td className="px-4 py-2">{u.completionTokens}</td>
|
||||||
|
<td className="px-4 py-2">{u.latencyMs}ms</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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">Retrieval & consent</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Mail-content augmentation and the BYOK consent prompt.</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4 border-b border-border">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">Retrieval leg</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Send recent mail content to the Server class's embedding model to answer questions grounded in the user's own mail.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => update({ retrievalEnabled: !config.retrievalEnabled })}
|
||||||
|
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${config.retrievalEnabled ? '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 ${config.retrievalEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3.5 space-y-2">
|
||||||
|
<label className="text-sm block">Consent text (shown once per version, before first BYOK/Public use)</label>
|
||||||
|
<textarea
|
||||||
|
value={config.consent?.text ?? ''}
|
||||||
|
onChange={(e) => update({ consent: { version: config.consent?.version ?? '1', text: e.target.value } })}
|
||||||
|
className="w-full min-h-20 rounded border border-input bg-background px-2.5 py-2 text-xs"
|
||||||
|
placeholder="Using a bring-your-own-key provider sends your question — and, if retrieval is on, related excerpts from your mail — to that provider's servers, outside this organisation. Continue?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2.5 flex-wrap">
|
||||||
|
<span className="text-sm">Version</span>
|
||||||
|
<input
|
||||||
|
value={config.consent?.version ?? ''}
|
||||||
|
onChange={(e) => update({ consent: { version: e.target.value, text: config.consent?.text ?? '' } })}
|
||||||
|
className="w-20 h-8 rounded border border-input bg-background px-2 text-xs text-center"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => update({ consent: { version: String(Number.parseInt(config.consent?.version || '0', 10) + 1), text: config.consent?.text ?? '' } })}
|
||||||
|
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70">
|
||||||
|
Bump version (re-prompt everyone)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,12 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
|||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
|
||||||
|
// `value` omitted and `hasValue` set instead - the server never echoes
|
||||||
|
// the raw secret to the client.
|
||||||
|
value?: unknown;
|
||||||
source: 'admin' | 'env' | 'default';
|
source: 'admin' | 'env' | 'default';
|
||||||
|
hasValue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthTab() {
|
export function AuthTab() {
|
||||||
@@ -267,8 +271,11 @@ export function AuthTab() {
|
|||||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<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} />
|
<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 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 Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||||
<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" />
|
<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" />
|
||||||
|
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
||||||
|
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Single Sign-On">
|
<Section title="Single Sign-On">
|
||||||
@@ -0,0 +1,721 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
||||||
|
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||||
|
import {
|
||||||
|
BRANDING_OVERRIDE_KEYS,
|
||||||
|
parseDomainBranding,
|
||||||
|
type BrandingOverrideKey,
|
||||||
|
type DomainBrandingEntry,
|
||||||
|
} from '@/lib/admin/domain-branding';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value?: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
hasValue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
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' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PWA_IMAGE_FIELDS = [
|
||||||
|
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
|
||||||
|
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PWA_TEXT_FIELDS = [
|
||||||
|
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
|
||||||
|
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PWA_COLOR_FIELDS = [
|
||||||
|
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
||||||
|
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
|
||||||
|
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||||
|
// Tighter rule for uploads: wildcards can only point to externally-hosted
|
||||||
|
// URLs, since we'd have no concrete subdomain to serve a file from.
|
||||||
|
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||||
|
|
||||||
|
export function BrandingTab() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||||
|
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 [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||||
|
const [addingHost, setAddingHost] = useState(false);
|
||||||
|
const [newHostInput, setNewHostInput] = useState('');
|
||||||
|
const [newHostError, setNewHostError] = useState<string | null>(null);
|
||||||
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const domainEntries = useMemo<DomainBrandingEntry[]>(
|
||||||
|
() => parseDomainBranding(config['domainBranding']?.value),
|
||||||
|
[config],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
|
||||||
|
setSelectedHost(null);
|
||||||
|
setEdits({});
|
||||||
|
}
|
||||||
|
}, [domainEntries, selectedHost]);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await apiFetch('/api/admin/config');
|
||||||
|
if (res.ok) setConfig(await res.json());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedEntry(): DomainBrandingEntry | null {
|
||||||
|
if (!selectedHost) return null;
|
||||||
|
return domainEntries.find(e => e.host === selectedHost) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
if (selectedHost) {
|
||||||
|
const entry = selectedEntry();
|
||||||
|
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
|
||||||
|
}
|
||||||
|
return (config[key]?.value as string) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverriddenInScope(key: string): boolean {
|
||||||
|
if (selectedHost) {
|
||||||
|
const entry = selectedEntry();
|
||||||
|
const v = entry?.[key as BrandingOverrideKey];
|
||||||
|
return typeof v === 'string' && v.length > 0;
|
||||||
|
}
|
||||||
|
return config[key]?.source === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUploadedFile = (key: string): boolean => {
|
||||||
|
const val = currentValue(key);
|
||||||
|
return val.startsWith('/api/admin/branding/');
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
|
||||||
|
if (!selectedHost) return domainEntries;
|
||||||
|
const next = domainEntries.slice();
|
||||||
|
const idx = next.findIndex(e => e.host === selectedHost);
|
||||||
|
const base: DomainBrandingEntry =
|
||||||
|
idx === -1 ? { host: selectedHost } : { ...next[idx] };
|
||||||
|
const writable = base as unknown as Record<string, string | undefined>;
|
||||||
|
for (const [key, value] of Object.entries(merge)) {
|
||||||
|
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
writable[key] = value;
|
||||||
|
} else {
|
||||||
|
delete writable[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (idx === -1) next.push(base);
|
||||||
|
else next[idx] = base;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const payload = selectedHost
|
||||||
|
? { domainBranding: buildUpdatedDomainBranding(edits) }
|
||||||
|
: edits;
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: selectedHost
|
||||||
|
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
|
||||||
|
: '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) {
|
||||||
|
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(slot);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('slot', slot);
|
||||||
|
if (selectedHost) formData.append('host', selectedHost);
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
// Refresh from server so domainBranding entries reflect the upload.
|
||||||
|
await fetchConfig();
|
||||||
|
void data;
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
setUploading(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUpload(slot: string) {
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const body: { slot: string; host?: string } = { slot };
|
||||||
|
if (selectedHost) body.host = selectedHost;
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (selectedHost) {
|
||||||
|
// Domain scope: drop the field from the entry and PATCH the array.
|
||||||
|
const updated = buildUpdatedDomainBranding({ [key]: '' });
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domainBranding: updated }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Default scope: revert via DELETE /api/admin/config
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddDomain() {
|
||||||
|
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
|
||||||
|
if (!host) {
|
||||||
|
setNewHostError('Enter a hostname');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!HOST_RE.test(host)) {
|
||||||
|
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (domainEntries.some(e => e.host === host)) {
|
||||||
|
setNewHostError('A branding entry for this host already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNewHostError(null);
|
||||||
|
|
||||||
|
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domainBranding: next }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setNewHostInput('');
|
||||||
|
setAddingHost(false);
|
||||||
|
setSelectedHost(host);
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setNewHostError(data.error || 'Failed to add domain');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteDomain() {
|
||||||
|
if (!selectedHost) return;
|
||||||
|
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = domainEntries.filter(e => e.host !== selectedHost);
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domainBranding: next }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSelectedHost(null);
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScopeChange(host: string | null) {
|
||||||
|
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
|
||||||
|
setSelectedHost(host);
|
||||||
|
setEdits({});
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Scope picker */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Scope</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleScopeChange(null)}
|
||||||
|
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
selectedHost === null
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Default
|
||||||
|
</button>
|
||||||
|
{domainEntries.map(entry => (
|
||||||
|
<button
|
||||||
|
key={entry.host}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleScopeChange(entry.host)}
|
||||||
|
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
selectedHost === entry.host
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.host}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!addingHost && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAddingHost(true); setNewHostError(null); }}
|
||||||
|
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add domain
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{addingHost && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={newHostInput}
|
||||||
|
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
|
||||||
|
placeholder="mail.example.com or *.example.com"
|
||||||
|
className="h-8 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddDomain}
|
||||||
|
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
|
||||||
|
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedHost ? (
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
|
||||||
|
Unset fields fall back to the Default values.
|
||||||
|
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteDomain}
|
||||||
|
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
Remove domain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{isOverriddenInScope(field.key) && (
|
||||||
|
<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' : selectedHost ? 'domain' : '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={selectedHost ? 'Enter URL (uploads only for default scope)' : '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 || wildcardScope}
|
||||||
|
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={wildcardScope ? 'Uploads disabled for wildcard hosts' : '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>
|
||||||
|
)}
|
||||||
|
{isOverriddenInScope(field.key) && !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={withBasePath(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">Progressive Web App</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{PWA_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>
|
||||||
|
{isOverriddenInScope(field.key) && (
|
||||||
|
<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' : selectedHost ? 'domain' : '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={selectedHost ? 'Enter URL (uploads only for default scope)' : '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 || wildcardScope}
|
||||||
|
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={wildcardScope ? 'Uploads disabled for wildcard hosts' : '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>
|
||||||
|
)}
|
||||||
|
{isOverriddenInScope(field.key) && !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={withBasePath(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>
|
||||||
|
))}
|
||||||
|
{PWA_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>
|
||||||
|
{isOverriddenInScope(field.key) && (
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||||
|
{selectedHost ? 'domain' : '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.placeholder}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
{isOverriddenInScope(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>
|
||||||
|
))}
|
||||||
|
{PWA_COLOR_FIELDS.map(field => {
|
||||||
|
const value = currentValue(field.key) || field.defaultValue;
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{isOverriddenInScope(field.key) && (
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||||
|
{selectedHost ? 'domain' : 'admin'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
|
||||||
|
title="Pick a color"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currentValue(field.key)}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
placeholder={field.defaultValue}
|
||||||
|
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
{isOverriddenInScope(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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
{isOverriddenInScope(field.key) && (
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||||
|
{selectedHost ? 'domain' : '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"
|
||||||
|
/>
|
||||||
|
{isOverriddenInScope(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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,13 +26,12 @@ export function DashboardTab() {
|
|||||||
const [status, setStatus] = useState<AdminStatus | null>(null);
|
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||||
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
const [pluginCount, setPluginCount] = useState(0);
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
const [themeCount, setThemeCount] = useState(0);
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||||
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
@@ -82,21 +81,14 @@ export function DashboardTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
|
||||||
try {
|
|
||||||
const jmapRes = await apiFetch('/api/config');
|
|
||||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
|
||||||
} catch {
|
|
||||||
setJmapHealth('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const w: string[] = [];
|
const w: string[] = [];
|
||||||
if (adminConfigRes.ok) {
|
if (adminConfigRes.ok) {
|
||||||
const sources = await adminConfigRes.json();
|
const sources = await adminConfigRes.json();
|
||||||
setConfigSources(sources);
|
setConfigSources(sources);
|
||||||
const sessionSecret = sources?.sessionSecret;
|
const sessionSecret = sources?.sessionSecret;
|
||||||
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
// Server redacts the raw value for sensitive keys; rely on hasValue,
|
||||||
|
// which is false when unset or matching a known placeholder default.
|
||||||
|
if (!sessionSecret?.hasValue) {
|
||||||
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||||
}
|
}
|
||||||
const adminPassword = sources?.adminPassword;
|
const adminPassword = sources?.adminPassword;
|
||||||
@@ -126,16 +118,6 @@ export function DashboardTab() {
|
|||||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
</SettingItem>
|
</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">
|
<SettingItem label="Last Login">
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-sm text-foreground">
|
||||||
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||||
@@ -96,10 +96,10 @@ export function LogsTab() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-muted/30">
|
<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-start 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-start 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-start 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>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||||
|
|
||||||
|
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -18,6 +21,7 @@ interface Extension {
|
|||||||
minAppVersion: string | null;
|
minAppVersion: string | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
|
installedVersion: string | null;
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
bannerUrl: string | null;
|
bannerUrl: string | null;
|
||||||
author: {
|
author: {
|
||||||
@@ -94,6 +98,15 @@ export function MarketplaceTab() {
|
|||||||
}, [searchInput]);
|
}, [searchInput]);
|
||||||
|
|
||||||
async function handleInstall(ext: Extension) {
|
async function handleInstall(ext: Extension) {
|
||||||
|
if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) {
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isUpdate = ext.installed;
|
||||||
|
const targetVersion = ext.latestVersion || '1.0.0';
|
||||||
setInstalling(ext.slug);
|
setInstalling(ext.slug);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
@@ -103,7 +116,7 @@ export function MarketplaceTab() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
slug: ext.slug,
|
slug: ext.slug,
|
||||||
version: ext.latestVersion || '1.0.0',
|
version: targetVersion,
|
||||||
type: ext.type,
|
type: ext.type,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -112,13 +125,22 @@ export function MarketplaceTab() {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
setMessage({
|
||||||
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
|
type: 'success',
|
||||||
|
text: isUpdate
|
||||||
|
? `"${ext.name}" updated to v${targetVersion}${warnings}`
|
||||||
|
: `"${ext.name}" installed successfully${warnings}`,
|
||||||
|
});
|
||||||
|
setExtensions(prev => prev.map(e =>
|
||||||
|
e.slug === ext.slug
|
||||||
|
? { ...e, installed: true, installedVersion: targetVersion }
|
||||||
|
: e,
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(null);
|
setInstalling(null);
|
||||||
}
|
}
|
||||||
@@ -149,7 +171,7 @@ export function MarketplaceTab() {
|
|||||||
placeholder="Search extensions..."
|
placeholder="Search extensions..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
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"
|
className="w-full h-9 ps-9 pe-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>
|
||||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
||||||
@@ -188,7 +210,7 @@ export function MarketplaceTab() {
|
|||||||
{loading && !error && (
|
{loading && !error && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -258,6 +280,13 @@ function ExtensionCard({
|
|||||||
}) {
|
}) {
|
||||||
const isPlugin = extension.type === 'plugin';
|
const isPlugin = extension.type === 'plugin';
|
||||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||||
|
const versionMismatch = !!extension.minAppVersion
|
||||||
|
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
|
||||||
|
const updateAvailable = extension.installed
|
||||||
|
&& !!extension.installedVersion
|
||||||
|
&& !!extension.latestVersion
|
||||||
|
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
|
||||||
|
&& !versionMismatch;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
@@ -346,12 +375,37 @@ function ExtensionCard({
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="px-4 pb-4 -mt-1">
|
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
|
||||||
{extension.installed ? (
|
{extension.installed && updateAvailable ? (
|
||||||
<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">
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||||
|
disabled={installing}
|
||||||
|
title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpCircle className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Update to v{extension.latestVersion}
|
||||||
|
</button>
|
||||||
|
) : 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"
|
||||||
|
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
|
||||||
|
>
|
||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
Installed
|
Installed
|
||||||
</span>
|
</span>
|
||||||
|
) : versionMismatch ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
|
||||||
|
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Requires v{extension.minAppVersion}+
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||||
+26
-3
@@ -3,6 +3,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||||
|
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||||
|
|
||||||
interface ConfigField {
|
interface ConfigField {
|
||||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
@@ -153,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -215,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="text-sm font-medium text-foreground block mb-1">
|
<label className="text-sm font-medium text-foreground block mb-1">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
{field.required && <span className="text-destructive ms-0.5">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{field.description && (
|
{field.description && (
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||||
@@ -248,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
value={formValues[key] ?? ''}
|
value={formValues[key] ?? ''}
|
||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
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"
|
className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -286,6 +288,27 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PluginAdminSection pluginId={pluginId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
|
||||||
|
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
|
||||||
|
* and the host gives it a dedicated iframe inside the admin panel.
|
||||||
|
*/
|
||||||
|
function PluginAdminSection({ pluginId }: { pluginId: string }) {
|
||||||
|
const offers = usePluginSlotOffers('admin-plugin-page');
|
||||||
|
const offer = offers.find((o) => o.pluginId === pluginId);
|
||||||
|
if (!offer) return null;
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Plugin admin panel
|
||||||
|
</div>
|
||||||
|
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,10 @@ export function PluginsTab() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
// Bundle held back by the pattern scanner, awaiting an explicit admin decision.
|
||||||
|
const [pendingScan, setPendingScan] = useState<
|
||||||
|
{ file: File; findings: Array<{ file: string; patterns: string[] }> } | null
|
||||||
|
>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
const [policyDirty, setPolicyDirty] = useState(false);
|
const [policyDirty, setPolicyDirty] = useState(false);
|
||||||
@@ -104,15 +108,17 @@ export function PluginsTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
// Upload a bundle. The scanner may refuse it for containing patterns that are
|
||||||
const file = e.target.files?.[0];
|
// expected in a vendored crypto library (openpgp.js, pkijs); in that case the
|
||||||
if (!file) return;
|
// server returns `canOverride` and we hold the file so the admin can review
|
||||||
|
// the findings and decide. `override` re-posts the same file with consent.
|
||||||
|
async function uploadPlugin(file: File, override: boolean) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
if (override) formData.append('overrideWarnings', 'true');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
@@ -122,13 +128,22 @@ export function PluginsTab() {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
setPendingScan(null);
|
||||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
const accepted = data.findings?.length
|
||||||
|
? ` — ${data.findings.length} scanner finding(s) accepted and logged`
|
||||||
|
: '';
|
||||||
|
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${accepted}` });
|
||||||
await fetchPlugins();
|
await fetchPlugins();
|
||||||
|
} else if (data.canOverride && Array.isArray(data.findings) && !override) {
|
||||||
|
// Hold the file rather than the error: the admin needs to see WHAT
|
||||||
|
// tripped, in WHICH file, before deciding.
|
||||||
|
setPendingScan({ file, findings: data.findings });
|
||||||
} else {
|
} else {
|
||||||
|
setPendingScan(null);
|
||||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
setPendingScan(null);
|
||||||
setMessage({ type: 'error', text: 'Upload failed' });
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
@@ -136,6 +151,13 @@ export function PluginsTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setPendingScan(null);
|
||||||
|
await uploadPlugin(file, false);
|
||||||
|
}
|
||||||
|
|
||||||
async function togglePlugin(id: string, enabled: boolean) {
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
@@ -302,6 +324,51 @@ export function PluginsTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingScan && (
|
||||||
|
<div className="border border-warning/40 bg-warning/5 rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-warning mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Scanner flagged <span className="font-mono">{pendingScan.file.name}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
These patterns can indicate malicious code, but they also appear in legitimate
|
||||||
|
minified crypto libraries such as openpgp.js and pkijs. Review the findings before
|
||||||
|
proceeding — installing anyway is recorded in the audit log.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{pendingScan.findings.map(f => (
|
||||||
|
<li key={f.file} className="text-xs font-mono bg-background/60 border border-border rounded px-2 py-1">
|
||||||
|
<span className="text-foreground">{f.file}</span>
|
||||||
|
<span className="text-muted-foreground"> — {f.patterns.join(', ')}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => uploadPlugin(pendingScan.file, true)}
|
||||||
|
disabled={uploading}
|
||||||
|
className="inline-flex items-center gap-2 h-8 px-3 rounded-md bg-destructive text-destructive-foreground text-xs font-medium hover:bg-destructive/90 disabled:opacity-50 transition-all"
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <AlertTriangle className="w-3.5 h-3.5" />}
|
||||||
|
Install anyway
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setPendingScan(null); setMessage(null); }}
|
||||||
|
disabled={uploading}
|
||||||
|
className="inline-flex items-center h-8 px-3 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50 transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border border-border rounded-lg">
|
<div className="border border-border rounded-lg">
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -6,13 +6,16 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
|||||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
|
||||||
|
// forward on policy load), so it is hidden from the admin UI.
|
||||||
|
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
|
||||||
|
|
||||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
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' },
|
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' },
|
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||||
|
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
|
||||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
@@ -21,6 +24,11 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
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.' },
|
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||||
|
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||||
|
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||||
|
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||||
|
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
|
||||||
|
aiAssistantEnabled: { label: 'AI Assistant (preview)', description: 'Show the AI Assistant settings tab. Local (Ollama on the user\'s own machine or this desktop app) is free and unmetered; public (bring-your-own-key) is available too but not yet monitored or metered — see docs/AI-ASSISTANT-CONCEPT.md.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESTRICTABLE_SETTINGS = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
@@ -28,7 +36,7 @@ const RESTRICTABLE_SETTINGS = [
|
|||||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
|
||||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||||
@@ -74,6 +82,18 @@ export function PolicyTab() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setPushRelayUrl(value: string) {
|
||||||
|
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePushRelayLocked() {
|
||||||
|
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleLocked(settingKey: string) {
|
function toggleLocked(settingKey: string) {
|
||||||
setPolicy(prev => {
|
setPolicy(prev => {
|
||||||
const existing = prev.restrictions[settingKey] || {};
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
@@ -183,6 +203,34 @@ export function PolicyTab() {
|
|||||||
</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">Push Relay</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 space-y-3">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={policy.pushRelayUrl ?? ''}
|
||||||
|
onChange={(e) => setPushRelayUrl(e.target.value)}
|
||||||
|
placeholder="https://notifications.relay.example.com"
|
||||||
|
className="w-full rounded border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!policy.pushRelayUrlLocked}
|
||||||
|
onChange={togglePushRelayLocked}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{categories.map(category => (
|
{categories.map(category => (
|
||||||
<div key={category} className="border border-border rounded-lg">
|
<div key={category} className="border border-border rounded-lg">
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
@@ -7,8 +7,9 @@ import { JmapServersSection } from './_jmap-servers-section';
|
|||||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value?: unknown;
|
||||||
source: 'admin' | 'env' | 'default';
|
source: 'admin' | 'env' | 'default';
|
||||||
|
hasValue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsTab() {
|
export function SettingsTab() {
|
||||||
@@ -116,7 +117,7 @@ export function SettingsTab() {
|
|||||||
<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" />
|
<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} />
|
<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') && (
|
{!!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">
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
<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.
|
<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>
|
</p>
|
||||||
@@ -124,6 +125,7 @@ export function SettingsTab() {
|
|||||||
)}
|
)}
|
||||||
<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="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} />
|
<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} />
|
||||||
|
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title="JMAP Servers (multi-server)">
|
<SettingsSection title="JMAP Servers (multi-server)">
|
||||||
@@ -143,7 +145,7 @@ export function SettingsTab() {
|
|||||||
onRevert={() => handleRevert('jmapServers')}
|
onRevert={() => handleRevert('jmapServers')}
|
||||||
/>
|
/>
|
||||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
{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">
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
<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.
|
<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>
|
</p>
|
||||||
@@ -118,9 +118,10 @@ export function TelemetryTab() {
|
|||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
Bulwark can send 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>;
|
running, on what platforms, and which features they use. It's <strong>off by
|
||||||
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
default</strong>; one click below enables it and helps us make the product better. No
|
||||||
|
email addresses, no hostnames, no IPs are sent.{' '}
|
||||||
<a
|
<a
|
||||||
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -138,8 +139,8 @@ export function TelemetryTab() {
|
|||||||
<div className="font-medium">Status</div>
|
<div className="font-medium">Status</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||||
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
{status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
|
||||||
{status.consent === 'off' && 'Heartbeats are off.'}
|
{status.consent === 'off' && 'Heartbeats are off (default).'}
|
||||||
{envOverridden && (
|
{envOverridden && (
|
||||||
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||||
)}
|
)}
|
||||||
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
|
|||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||||
|
|
||||||
const BUILTIN_THEME_OPTIONS = [
|
// Derive from the single source of truth so newly added built-in themes show
|
||||||
{ id: 'builtin-nord', name: 'Nord' },
|
// up here automatically (was previously a hardcoded subset — see #496).
|
||||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
|
||||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ThemeEntry {
|
interface ThemeEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, Plus, X } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface VncDirectoryFormData {
|
||||||
|
enabled: boolean;
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
samlEnabled: boolean;
|
||||||
|
samlIdpUrl: string;
|
||||||
|
samlSpCert: string;
|
||||||
|
samlIssuer: string;
|
||||||
|
ldapEnabled: boolean;
|
||||||
|
ldapUri: string;
|
||||||
|
ldapBindDn: string;
|
||||||
|
ldapBindPassword: string;
|
||||||
|
ldapSearchBase: string;
|
||||||
|
ldapType: 'openldap' | 'ms-ad';
|
||||||
|
tfaEnabled: boolean;
|
||||||
|
oidcEnabled: boolean;
|
||||||
|
oidcClientId: string;
|
||||||
|
oidcDiscoveryUrl: string;
|
||||||
|
sessionTtl: number;
|
||||||
|
federatedApps: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLANK_FORM: VncDirectoryFormData = {
|
||||||
|
enabled: false,
|
||||||
|
apiUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
samlEnabled: false,
|
||||||
|
samlIdpUrl: '',
|
||||||
|
samlSpCert: '',
|
||||||
|
samlIssuer: '',
|
||||||
|
ldapEnabled: false,
|
||||||
|
ldapUri: '',
|
||||||
|
ldapBindDn: '',
|
||||||
|
ldapBindPassword: '',
|
||||||
|
ldapSearchBase: '',
|
||||||
|
ldapType: 'openldap',
|
||||||
|
tfaEnabled: false,
|
||||||
|
oidcEnabled: false,
|
||||||
|
oidcClientId: '',
|
||||||
|
oidcDiscoveryUrl: '',
|
||||||
|
sessionTtl: 28800,
|
||||||
|
federatedApps: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VncDirectoryTab() {
|
||||||
|
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
|
||||||
|
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(() => { fetchConfig(); }, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/vncdirectory');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setConfig(data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField<K extends keyof VncDirectoryFormData>(key: K, value: VncDirectoryFormData[K]) {
|
||||||
|
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBool(key: keyof VncDirectoryFormData) {
|
||||||
|
setConfig((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFederatedApp(name: string, url: string) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
federatedApps: { ...prev.federatedApps, [name]: url },
|
||||||
|
}));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFederatedApp(name: string) {
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = { ...prev.federatedApps };
|
||||||
|
delete next[name];
|
||||||
|
return { ...prev, federatedApps: next };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/vncdirectory', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'VNCdirectory configuration saved.' });
|
||||||
|
setDirty(false);
|
||||||
|
await fetchConfig();
|
||||||
|
} 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 federatedAppsList = Object.entries(config.federatedApps);
|
||||||
|
|
||||||
|
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">VNCdirectory</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Centralized identity and directory integration (SAML, LDAP, 2FA)
|
||||||
|
</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 configuration
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Enable VNCdirectory Integration">
|
||||||
|
<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">Enabled</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Turn on VNCdirectory integration for identity management, SSO, and directory services
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleBool('enabled')}
|
||||||
|
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||||
|
config.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 ${
|
||||||
|
config.enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{config.enabled && (
|
||||||
|
<>
|
||||||
|
<Section title="Connection">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<TextRow
|
||||||
|
label="VNCdirectory URL"
|
||||||
|
value={config.apiUrl}
|
||||||
|
onChange={(v) => updateField('apiUrl', v)}
|
||||||
|
placeholder="https://vncdirectory.example.com"
|
||||||
|
/>
|
||||||
|
<PasswordRow
|
||||||
|
label="API Key"
|
||||||
|
value={config.apiKey}
|
||||||
|
onChange={(v) => updateField('apiKey', v)}
|
||||||
|
placeholder="Enter API key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="SAML / Identity Provider">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<ToggleRow
|
||||||
|
label="SAML Enabled"
|
||||||
|
description="Enable SAML single sign-on via VNCdirectory"
|
||||||
|
value={config.samlEnabled}
|
||||||
|
onChange={() => toggleBool('samlEnabled')}
|
||||||
|
/>
|
||||||
|
{config.samlEnabled && (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label="Identity Provider URL"
|
||||||
|
value={config.samlIdpUrl}
|
||||||
|
onChange={(v) => updateField('samlIdpUrl', v)}
|
||||||
|
placeholder="https://idp.example.com/saml2/idp"
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label="Issuer Name (Entity ID)"
|
||||||
|
value={config.samlIssuer}
|
||||||
|
onChange={(v) => updateField('samlIssuer', v)}
|
||||||
|
placeholder="urn:example:vncmail"
|
||||||
|
/>
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2">
|
||||||
|
<label className="text-sm text-foreground">
|
||||||
|
Service Provider Certificate (X.509)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={config.samlSpCert}
|
||||||
|
onChange={(e) => updateField('samlSpCert', e.target.value)}
|
||||||
|
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-vertical"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="LDAP Directory">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<ToggleRow
|
||||||
|
label="LDAP Enabled"
|
||||||
|
description="Query user directory via LDAP for contact lookups and authentication"
|
||||||
|
value={config.ldapEnabled}
|
||||||
|
onChange={() => toggleBool('ldapEnabled')}
|
||||||
|
/>
|
||||||
|
{config.ldapEnabled && (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label="LDAP Server URI"
|
||||||
|
value={config.ldapUri}
|
||||||
|
onChange={(v) => updateField('ldapUri', v)}
|
||||||
|
placeholder="ldaps://ldap.example.com:636"
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label="Bind DN"
|
||||||
|
value={config.ldapBindDn}
|
||||||
|
onChange={(v) => updateField('ldapBindDn', v)}
|
||||||
|
placeholder="cn=readonly,dc=example,dc=com"
|
||||||
|
/>
|
||||||
|
<PasswordRow
|
||||||
|
label="Bind Password"
|
||||||
|
value={config.ldapBindPassword}
|
||||||
|
onChange={(v) => updateField('ldapBindPassword', v)}
|
||||||
|
placeholder="Enter LDAP bind password"
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label="Search Base"
|
||||||
|
value={config.ldapSearchBase}
|
||||||
|
onChange={(v) => updateField('ldapSearchBase', v)}
|
||||||
|
placeholder="ou=users,dc=example,dc=com"
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
label="LDAP Type"
|
||||||
|
value={config.ldapType}
|
||||||
|
options={[
|
||||||
|
{ value: 'openldap', label: 'OpenLDAP' },
|
||||||
|
{ value: 'ms-ad', label: 'Microsoft Active Directory' },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Authentication">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<ToggleRow
|
||||||
|
label="Enforce 2FA/TOTP"
|
||||||
|
description="Require two-factor authentication for all users"
|
||||||
|
value={config.tfaEnabled}
|
||||||
|
onChange={() => toggleBool('tfaEnabled')}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="OpenID Connect (OIDC)"
|
||||||
|
description="Enable OIDC login alongside or instead of SAML"
|
||||||
|
value={config.oidcEnabled}
|
||||||
|
onChange={() => toggleBool('oidcEnabled')}
|
||||||
|
/>
|
||||||
|
{config.oidcEnabled && (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label="OIDC Client ID"
|
||||||
|
value={config.oidcClientId}
|
||||||
|
onChange={(v) => updateField('oidcClientId', v)}
|
||||||
|
placeholder="vncmail-client"
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label="OIDC Discovery URL"
|
||||||
|
value={config.oidcDiscoveryUrl}
|
||||||
|
onChange={(v) => updateField('oidcDiscoveryUrl', v)}
|
||||||
|
placeholder="https://idp.example.com/.well-known/openid-configuration"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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">Session TTL (seconds)</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
How long SSO sessions remain valid. Default: 8 hours (28800)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={config.sessionTtl}
|
||||||
|
onChange={(e) => updateField('sessionTtl', Number(e.target.value))}
|
||||||
|
className="h-8 w-full sm:w-32 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Federated Applications">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
Configure SSO redirect URLs for other VNC applications. Users signed into one
|
||||||
|
app will be transparently authenticated when navigating to another.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{federatedAppsList.map(([appName, url]) => (
|
||||||
|
<div
|
||||||
|
key={appName}
|
||||||
|
className="flex flex-col sm:flex-row items-start sm:items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={appName}
|
||||||
|
readOnly
|
||||||
|
className="h-8 w-full sm:w-36 rounded-md border border-input bg-muted/50 px-2.5 text-sm text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setFederatedApp(appName, e.target.value)}
|
||||||
|
placeholder="https://vnc.example.com/auth/sso"
|
||||||
|
className="h-8 w-full sm:flex-1 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFederatedApp(appName)}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
title={`Remove ${appName}`}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<AddFederatedApp
|
||||||
|
existingKeys={new Set(Object.keys(config.federatedApps))}
|
||||||
|
onAdd={(name, url) => setFederatedApp(name, url)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddFederatedApp({
|
||||||
|
existingKeys,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
existingKeys: Set<string>;
|
||||||
|
onAdd: (name: string, url: string) => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!adding) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add federated app
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Enter an application name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||||
|
setError('Name must contain only letters, numbers, hyphens, and underscores');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existingKeys.has(trimmed)) {
|
||||||
|
setError('An app with this name already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!url.trim()) {
|
||||||
|
setError('Enter an SSO URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
onAdd(trimmed, url.trim());
|
||||||
|
setName('');
|
||||||
|
setUrl('');
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setAdding(false);
|
||||||
|
setName('');
|
||||||
|
setUrl('');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => { setName(e.target.value); setError(null); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||||
|
placeholder="App name (e.g. vnctalk)"
|
||||||
|
className="h-8 w-full sm:w-36 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
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => { setUrl(e.target.value); setError(null); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||||
|
placeholder="https://vnctalk.example.com/auth/sso"
|
||||||
|
className="h-8 w-full sm:flex-1 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"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="inline-flex items-center h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
|
</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>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: 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">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-8 w-full sm:w-72 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const isMasked = value === '••••••';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div 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">{label}</span>
|
||||||
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
<input
|
||||||
|
type={isMasked ? 'text' : 'password'}
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder || (isMasked ? 'Saved - type to replace' : undefined)}
|
||||||
|
className="h-8 w-full sm:w-72 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: boolean;
|
||||||
|
onChange: () => 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">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onChange}
|
||||||
|
className={`shrink-0 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
onChange: (v: 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">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(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} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export default function ChangePasswordPage() {
|
|||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={e => setCurrentPassword(e.target.value)}
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
required
|
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"
|
className="w-full h-9 ps-9 pe-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"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
Scale,
|
Scale,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Bot,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Activity,
|
Activity,
|
||||||
@@ -32,7 +34,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
|||||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
// 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 -
|
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||||
@@ -54,7 +56,9 @@ const NAV_GROUPS: ReadonlyArray<{
|
|||||||
{ tab: 'settings', label: 'Settings', icon: Settings },
|
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||||
|
{ tab: 'vncdirectory', label: 'VNCdirectory', icon: Key },
|
||||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||||
|
{ tab: 'ai-policy', label: 'AI', icon: Bot },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,9 +94,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const logoUrl = resolvedTheme === 'dark'
|
const logoUrl = withBasePath(resolvedTheme === 'dark'
|
||||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
|
||||||
|
|
||||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||||
@@ -212,7 +216,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
active
|
active
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -248,7 +252,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<Link
|
<Link
|
||||||
href="/admin/change-password"
|
href="/admin/change-password"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
pathname === '/admin/change-password'
|
pathname === '/admin/change-password'
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -263,7 +267,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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"
|
className="w-full text-start 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" />
|
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
Sign out
|
Sign out
|
||||||
@@ -275,7 +279,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail (desktop only) */}
|
{/* 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">
|
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||||
) : (
|
) : (
|
||||||
@@ -326,12 +330,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Admin Sidebar (desktop only) */}
|
{/* Admin Sidebar (desktop only) */}
|
||||||
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
|
<aside className="hidden md:flex w-60 border-e 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">
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||||
) : (
|
) : (
|
||||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
<Shield className="w-5 h-5 text-primary me-2" />
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +354,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{/* Mobile drawer */}
|
{/* Mobile drawer */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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',
|
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||||
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
)}
|
)}
|
||||||
aria-label="Admin navigation"
|
aria-label="Admin navigation"
|
||||||
@@ -359,9 +363,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
<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">
|
<div className="flex items-center min-w-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||||
) : (
|
) : (
|
||||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
<Shield className="w-5 h-5 text-primary me-2" />
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,9 +395,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</button>
|
</button>
|
||||||
<div className="flex items-center min-w-0">
|
<div className="flex items-center min-w-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||||
) : (
|
) : (
|
||||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
<Shield className="w-5 h-5 text-primary me-2" />
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Shield } from 'lucide-react';
|
import { Shield } from 'lucide-react';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
+67
-12
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowUpCircle,
|
||||||
Download,
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
@@ -21,6 +22,9 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||||
|
|
||||||
|
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||||
|
|
||||||
interface PreviewData {
|
interface PreviewData {
|
||||||
extension: {
|
extension: {
|
||||||
@@ -70,6 +74,7 @@ interface PreviewData {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
|
installedVersion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RISKY_PERMISSIONS = new Set([
|
const RISKY_PERMISSIONS = new Set([
|
||||||
@@ -115,6 +120,8 @@ export default function MarketplacePreviewPage() {
|
|||||||
|
|
||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
const isUpdate = data.installed;
|
||||||
|
const targetVersion = data.extension.latestVersion || '1.0.0';
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
@@ -123,20 +130,25 @@ export default function MarketplacePreviewPage() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
slug: data.extension.slug,
|
slug: data.extension.slug,
|
||||||
version: data.extension.latestVersion || '1.0.0',
|
version: targetVersion,
|
||||||
type: data.extension.type,
|
type: data.extension.type,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||||
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
|
setMessage({
|
||||||
setData(prev => prev ? { ...prev, installed: true } : prev);
|
type: 'success',
|
||||||
|
text: isUpdate
|
||||||
|
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
|
||||||
|
: `"${data.extension.name}" installed${warnings}`,
|
||||||
|
});
|
||||||
|
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: 'error', text: body.error || 'Installation failed' });
|
setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
}
|
}
|
||||||
@@ -174,7 +186,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -200,6 +212,12 @@ export default function MarketplacePreviewPage() {
|
|||||||
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||||
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
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;
|
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||||
|
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
|
||||||
|
const updateAvailable = data.installed
|
||||||
|
&& !!data.installedVersion
|
||||||
|
&& !!ext.latestVersion
|
||||||
|
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
|
||||||
|
&& !versionMismatch;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
@@ -244,11 +262,22 @@ export default function MarketplacePreviewPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<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>
|
<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" />}
|
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||||
{data.installed && (
|
{data.installed && !updateAvailable && (
|
||||||
<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">
|
<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"
|
||||||
|
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
|
||||||
|
>
|
||||||
<Check className="w-3 h-3" /> Installed
|
<Check className="w-3 h-3" /> Installed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{data.installed && updateAvailable && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
|
||||||
|
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="w-3 h-3" /> Update available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
<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 ${
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
@@ -275,6 +304,17 @@ export default function MarketplacePreviewPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||||
{data.installed ? (
|
{data.installed ? (
|
||||||
<>
|
<>
|
||||||
|
{updateAvailable && (
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={installing || !!bundle.error}
|
||||||
|
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
|
||||||
|
Update to v{ext.latestVersion}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
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"
|
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"
|
||||||
@@ -294,8 +334,11 @@ export default function MarketplacePreviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
disabled={installing || !!bundle.error}
|
disabled={installing || !!bundle.error || versionMismatch}
|
||||||
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"
|
title={versionMismatch
|
||||||
|
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
|
||||||
|
: undefined}
|
||||||
|
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 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||||
Install
|
Install
|
||||||
@@ -310,6 +353,18 @@ export default function MarketplacePreviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{versionMismatch && (
|
||||||
|
<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">Update Bulwark to install this extension</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-90">
|
||||||
|
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{bundle.error && (
|
{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">
|
<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" />
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
@@ -457,7 +512,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
<section className="border border-border rounded-lg">
|
<section className="border border-border rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowManifest(v => !v)}
|
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"
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -477,7 +532,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
<section className="border border-border rounded-lg">
|
<section className="border border-border rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSource(v => !v)}
|
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"
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -7,12 +7,14 @@ import { SettingsTab } from './_tabs/settings';
|
|||||||
import { BrandingTab } from './_tabs/branding';
|
import { BrandingTab } from './_tabs/branding';
|
||||||
import { AuthTab } from './_tabs/auth';
|
import { AuthTab } from './_tabs/auth';
|
||||||
import { PolicyTab } from './_tabs/policy';
|
import { PolicyTab } from './_tabs/policy';
|
||||||
|
import { AiPolicyTab } from './_tabs/ai-policy';
|
||||||
import { PluginsTab } from './_tabs/plugins';
|
import { PluginsTab } from './_tabs/plugins';
|
||||||
import { ThemesTab } from './_tabs/themes';
|
import { ThemesTab } from './_tabs/themes';
|
||||||
import { MarketplaceTab } from './_tabs/marketplace';
|
import { MarketplaceTab } from './_tabs/marketplace';
|
||||||
import { VersionTab } from './_tabs/version';
|
import { VersionTab } from './_tabs/version';
|
||||||
import { TelemetryTab } from './_tabs/telemetry';
|
import { TelemetryTab } from './_tabs/telemetry';
|
||||||
import { LogsTab } from './_tabs/logs';
|
import { LogsTab } from './_tabs/logs';
|
||||||
|
import { VncDirectoryTab } from './_tabs/vncdirectory';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const activeTab = useAdminTabStore((s) => s.activeTab);
|
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||||
@@ -39,11 +41,13 @@ export default function AdminPage() {
|
|||||||
case 'branding': return <BrandingTab />;
|
case 'branding': return <BrandingTab />;
|
||||||
case 'auth': return <AuthTab />;
|
case 'auth': return <AuthTab />;
|
||||||
case 'policy': return <PolicyTab />;
|
case 'policy': return <PolicyTab />;
|
||||||
|
case 'ai-policy': return <AiPolicyTab />;
|
||||||
case 'plugins': return <PluginsTab />;
|
case 'plugins': return <PluginsTab />;
|
||||||
case 'themes': return <ThemesTab />;
|
case 'themes': return <ThemesTab />;
|
||||||
case 'marketplace': return <MarketplaceTab />;
|
case 'marketplace': return <MarketplaceTab />;
|
||||||
case 'version': return <VersionTab />;
|
case 'version': return <VersionTab />;
|
||||||
case 'telemetry': return <TelemetryTab />;
|
case 'telemetry': return <TelemetryTab />;
|
||||||
case 'logs': return <LogsTab />;
|
case 'logs': return <LogsTab />;
|
||||||
|
case 'vncdirectory': return <VncDirectoryTab />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
redirect('/admin?tab=vncdirectory');
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ export default function GlobalError({
|
|||||||
onClick={reset}
|
onClick={reset}
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 me-2" />
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { getLocaleDirection } from "@/i18n/direction";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
|
import { FaviconBadge } from "@/components/favicon-badge";
|
||||||
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
import {
|
||||||
|
matchDomainBranding,
|
||||||
|
parseDomainBranding,
|
||||||
|
pickRequestHost,
|
||||||
|
} from "@/lib/admin/domain-branding";
|
||||||
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
|
import { locales } from "@/i18n/routing";
|
||||||
|
import "../globals.css";
|
||||||
|
|
||||||
|
// This layout renders <html> and sits ABOVE the [locale] segment, so
|
||||||
|
// next-intl's getLocale() returns the default locale here - emitting
|
||||||
|
// <html lang="en"> on e.g. /de pages, which makes browsers offer to
|
||||||
|
// "translate this page". Recover the active locale from the request pathname
|
||||||
|
// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie /
|
||||||
|
// Accept-Language) when the path carries no locale segment.
|
||||||
|
async function resolveRequestLocale(): Promise<string> {
|
||||||
|
const pathname = (await headers()).get("x-pathname") || "";
|
||||||
|
const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s));
|
||||||
|
return seg ?? (await getLocale());
|
||||||
|
}
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
// The <head> favicon must honor per-domain branding, exactly like
|
||||||
|
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
|
||||||
|
// request host and prefer its override; fall back to the global
|
||||||
|
// admin/env/default value when the host has no favicon override (#585).
|
||||||
|
const host = pickRequestHost(await headers());
|
||||||
|
const domainOverride = matchDomainBranding(
|
||||||
|
host,
|
||||||
|
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||||
|
).faviconUrl;
|
||||||
|
const faviconUrl =
|
||||||
|
domainOverride && domainOverride.length > 0
|
||||||
|
? domainOverride
|
||||||
|
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||||
|
// Localize the <head> description to match the UI language; a hardcoded
|
||||||
|
// English description is another signal that makes Chrome offer to
|
||||||
|
// "translate this page". Resolve the locale from the request path, since this
|
||||||
|
// layout is above the [locale] segment (see resolveRequestLocale).
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = await getTranslations({ locale });
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
|
description: t("meta_description"),
|
||||||
|
// A private webmail should not be indexed by search engines. This is opt-in
|
||||||
|
// via Settings -> General; the default (false) emits noindex/nofollow.
|
||||||
|
robots: configManager.get<boolean>("searchEngineIndexing", false)
|
||||||
|
? { index: true, follow: true }
|
||||||
|
: { index: false, follow: false },
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
|
},
|
||||||
|
formatDetection: {
|
||||||
|
telephone: false,
|
||||||
|
},
|
||||||
|
icons: { icon: withBasePath(faviconUrl) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||||
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale} dir={getLocaleDirection(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
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('theme-storage');
|
||||||
|
const theme = stored ? JSON.parse(stored).state.theme : 'system';
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
const resolved = theme === 'system' ? systemTheme : theme;
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(resolved);
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.classList.add('light');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
|
<FaviconBadge />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock } from 'lucide-react';
|
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ interface WizardConfig {
|
|||||||
// Security
|
// Security
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
settingsSyncEnabled: boolean;
|
settingsSyncEnabled: boolean;
|
||||||
|
telemetryEnabled: boolean;
|
||||||
// Logging
|
// Logging
|
||||||
logFormat: 'text' | 'json';
|
logFormat: 'text' | 'json';
|
||||||
logLevel: 'error' | 'warn' | 'info' | 'debug';
|
logLevel: 'error' | 'warn' | 'info' | 'debug';
|
||||||
@@ -66,6 +67,7 @@ const EMPTY_CONFIG: WizardConfig = {
|
|||||||
oauthIssuerUrl: '',
|
oauthIssuerUrl: '',
|
||||||
sessionSecret: '',
|
sessionSecret: '',
|
||||||
settingsSyncEnabled: true,
|
settingsSyncEnabled: true,
|
||||||
|
telemetryEnabled: false,
|
||||||
logFormat: 'text',
|
logFormat: 'text',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
faviconUrl: '',
|
faviconUrl: '',
|
||||||
@@ -101,6 +103,14 @@ export default function SetupWizardPage() {
|
|||||||
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
|
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
|
||||||
const [stepIndex, setStepIndex] = useState(0);
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
const [completed, setCompleted] = useState(false);
|
const [completed, setCompleted] = useState(false);
|
||||||
|
// Resolved in a post-mount effect, not at render, so the server-rendered
|
||||||
|
// HTML (where window is absent) matches the client's first paint and
|
||||||
|
// doesn't trip a hydration mismatch.
|
||||||
|
const [insecureContext, setInsecureContext] = useState(false);
|
||||||
|
const [insecureAcknowledged, setInsecureAcknowledged] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setInsecureContext(detectInsecureContext());
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ─── Initial status load ────────────────────────────────────────────────
|
// ─── Initial status load ────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,6 +183,10 @@ export default function SetupWizardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Render shell ───────────────────────────────────────────────────────
|
// ─── Render shell ───────────────────────────────────────────────────────
|
||||||
|
if (insecureContext && !insecureAcknowledged) {
|
||||||
|
return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (bootstrapping) {
|
if (bootstrapping) {
|
||||||
return <CenteredCard><p className="text-muted-foreground">Loading…</p></CenteredCard>;
|
return <CenteredCard><p className="text-muted-foreground">Loading…</p></CenteredCard>;
|
||||||
}
|
}
|
||||||
@@ -246,7 +260,7 @@ export default function SetupWizardPage() {
|
|||||||
// edge cases that swallow client-side replaces after the
|
// edge cases that swallow client-side replaces after the
|
||||||
// setupComplete flag flips.
|
// setupComplete flag flips.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.assign('/admin/login');
|
window.location.assign(`${getPathPrefix()}/admin/login`);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -328,13 +342,13 @@ function CompletedScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-6 space-y-2">
|
<div className="mt-6 space-y-2">
|
||||||
<a
|
<a
|
||||||
href="/admin/login"
|
href={`${getPathPrefix()}/admin/login`}
|
||||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
Sign in to admin dashboard
|
Sign in to admin dashboard
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href={`${getPathPrefix()}/`}
|
||||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||||
>
|
>
|
||||||
Open webmail login
|
Open webmail login
|
||||||
@@ -347,6 +361,44 @@ function CompletedScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InsecureContextScreen({ onContinue }: { onContinue: () => void }) {
|
||||||
|
const httpsUrl =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? `https://${window.location.host}${window.location.pathname}${window.location.search}`
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<CenteredCard>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4">
|
||||||
|
<ShieldAlert className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold">You're running setup over plain HTTP</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||||
|
The setup token and admin password you enter here will travel in cleartext.
|
||||||
|
Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
{httpsUrl && (
|
||||||
|
<a
|
||||||
|
href={httpsUrl}
|
||||||
|
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Try HTTPS
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onContinue}
|
||||||
|
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
Continue over HTTP
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CenteredCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AlreadyConfiguredScreen() {
|
function AlreadyConfiguredScreen() {
|
||||||
return (
|
return (
|
||||||
<CenteredCard>
|
<CenteredCard>
|
||||||
@@ -372,13 +424,13 @@ function AlreadyConfiguredScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-6 space-y-2">
|
<div className="mt-6 space-y-2">
|
||||||
<a
|
<a
|
||||||
href="/admin/login"
|
href={`${getPathPrefix()}/admin/login`}
|
||||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
Sign in to admin dashboard
|
Sign in to admin dashboard
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href={`${getPathPrefix()}/`}
|
||||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||||
>
|
>
|
||||||
Open webmail login
|
Open webmail login
|
||||||
@@ -690,6 +742,21 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isPrivateOrLocalHostUrl(config.jmapServerUrl) && (
|
||||||
|
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 self-center">
|
||||||
|
<p className="text-sm font-medium text-foreground leading-relaxed">
|
||||||
|
This URL only resolves locally.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
|
||||||
|
Mail is fetched directly from the user's browser, so the JMAP URL must be reachable from anywhere users sign in - not just this machine or LAN. Use a public hostname (e.g. <code className="font-mono text-xs">https://mail.example.com</code>) in production.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{probe && probe.url === config.jmapServerUrl && (
|
{probe && probe.url === config.jmapServerUrl && (
|
||||||
probe.status === 'jmap_detected' ? (
|
probe.status === 'jmap_detected' ? (
|
||||||
<div className="mt-2 p-3 rounded-xl border border-success/20 bg-success/5 flex items-start gap-3">
|
<div className="mt-2 p-3 rounded-xl border border-success/20 bg-success/5 flex items-start gap-3">
|
||||||
@@ -713,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="mt-3 ml-[3.25rem] flex items-center gap-2 cursor-pointer">
|
<label className="mt-3 ms-[3.25rem] flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={confirmedNonJmap}
|
checked={confirmedNonJmap}
|
||||||
@@ -803,7 +870,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasRowErrors && (
|
{hasRowErrors && (
|
||||||
<ul className="text-xs text-destructive list-disc pl-5 space-y-0.5">
|
<ul className="text-xs text-destructive list-disc ps-5 space-y-0.5">
|
||||||
{rowErrors.map((err, i) => (
|
{rowErrors.map((err, i) => (
|
||||||
<li key={i}>{err}</li>
|
<li key={i}>{err}</li>
|
||||||
))}
|
))}
|
||||||
@@ -937,8 +1004,11 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const values: Partial<WizardConfig> = {
|
// telemetryConsent is persisted to the telemetry state file by the API,
|
||||||
|
// not to admin config - see app/api/setup/step/route.ts.
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
settingsSyncEnabled: config.settingsSyncEnabled,
|
settingsSyncEnabled: config.settingsSyncEnabled,
|
||||||
|
telemetryConsent: config.telemetryEnabled ? 'on' : 'off',
|
||||||
};
|
};
|
||||||
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
|
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
|
||||||
await onNext('security', values);
|
await onNext('security', values);
|
||||||
@@ -1003,6 +1073,15 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
|||||||
hint="Stores user preferences server-side, encrypted with the session secret."
|
hint="Stores user preferences server-side, encrypted with the session secret."
|
||||||
disabled={!config.sessionSecret}
|
disabled={!config.sessionSecret}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<Toggle
|
||||||
|
checked={config.telemetryEnabled}
|
||||||
|
onChange={(v) => setConfig({ ...config, telemetryEnabled: v })}
|
||||||
|
label="Send anonymous usage stats to help improve Bulwark"
|
||||||
|
hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Footer>
|
<Footer>
|
||||||
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
|
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
|
||||||
<PrimaryButton type="submit" disabled={submitting}>
|
<PrimaryButton type="submit" disabled={submitting}>
|
||||||
@@ -1264,7 +1343,7 @@ function BrandingAsset({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{value ? (
|
{value ? (
|
||||||
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
|
<img src={withBasePath(value)} alt="" className="max-w-full max-h-full object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
|
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
|
||||||
)}
|
)}
|
||||||
@@ -1305,7 +1384,7 @@ function BrandingAsset({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showUrlField && (
|
{showUrlField && (
|
||||||
<div className="mt-3 pl-[4.75rem]">
|
<div className="mt-3 ps-[4.75rem]">
|
||||||
<Input
|
<Input
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -1315,7 +1394,7 @@ function BrandingAsset({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
<p className="mt-2 ps-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1415,6 +1494,7 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
|
|||||||
: 'Off'
|
: 'Off'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SummaryRow label="Anonymous telemetry" value={config.telemetryEnabled ? 'On' : 'Off'} />
|
||||||
</SummaryGroup>
|
</SummaryGroup>
|
||||||
|
|
||||||
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
|
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
|
||||||
@@ -1521,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-baseline gap-3 text-sm">
|
<div className="flex justify-between items-baseline gap-3 text-sm">
|
||||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
<span className={'text-foreground text-end truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
||||||
{value || <span className="text-muted-foreground italic">-</span>}
|
{value || <span className="text-muted-foreground italic">-</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1729,6 +1809,57 @@ function isInsecureHttpUrl(url: string): boolean {
|
|||||||
return /^http:\/\//i.test(url.trim());
|
return /^http:\/\//i.test(url.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JMAP URL is called directly from the user's browser. A URL that only
|
||||||
|
* resolves on the operator's machine or LAN (localhost, RFC1918, .local mDNS)
|
||||||
|
* works during setup but breaks for any real user. Surface a soft warning
|
||||||
|
* so the operator catches this before going live.
|
||||||
|
*/
|
||||||
|
function isPrivateOrLocalHostUrl(url: string): boolean {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
let host: string;
|
||||||
|
try {
|
||||||
|
host = new URL(trimmed).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Strip IPv6 brackets, if any.
|
||||||
|
if (host.startsWith('[') && host.endsWith(']')) {
|
||||||
|
host = host.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (host === 'localhost' || host.endsWith('.localhost')) return true;
|
||||||
|
if (host.endsWith('.local')) return true;
|
||||||
|
if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;
|
||||||
|
// IPv4 literal: only flag the well-known private/loopback/link-local ranges.
|
||||||
|
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||||
|
if (v4) {
|
||||||
|
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
||||||
|
if (a === 10) return true;
|
||||||
|
if (a === 127) return true;
|
||||||
|
if (a === 169 && b === 254) return true;
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||||
|
if (a === 192 && b === 168) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectInsecureContext(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
if (window.location.protocol !== 'http:') return false;
|
||||||
|
// Browsers treat localhost/loopback as "potentially trustworthy" and accept
|
||||||
|
// Secure cookies even without TLS, so the wizard still works there. In dev
|
||||||
|
// we still want to render the warning so we can preview it without spinning
|
||||||
|
// up a non-loopback host.
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const isLoopback =
|
||||||
|
host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||||
|
if (isLoopback && process.env.NODE_ENV !== 'development') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function humanError(e: unknown): string {
|
function humanError(e: unknown): string {
|
||||||
if (e instanceof Error) return e.message;
|
if (e instanceof Error) return e.message;
|
||||||
if (typeof e === 'string') return e;
|
if (typeof e === 'string') return e;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||||
|
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||||
|
// asset request from this layout - bundled fonts, globals.css, etc. - is then
|
||||||
|
// cross-origin from the "null" origin to the host origin and gets blocked
|
||||||
|
// (fonts in particular require CORS). So this layout is intentionally minimal:
|
||||||
|
// no font imports, no CSS imports. Plugins ship their own styles, and both the
|
||||||
|
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
|
||||||
|
// so the sandbox never fetches same-origin assets itself.
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Plugin sandbox',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||||
|
|
||||||
|
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||||
|
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||||
|
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||||
|
// approval) is enforced host-side before this route is ever framed; the page
|
||||||
|
// itself carries no extra privilege.
|
||||||
|
//
|
||||||
|
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||||
|
// Next's injected hydration/chunk scripts.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function PrivilegedPluginSandboxPage() {
|
||||||
|
return <SandboxRuntime />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||||
|
|
||||||
|
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||||
|
// Next's injected hydration/chunk scripts. With force-static, those scripts
|
||||||
|
// render without a nonce and the strict sandbox CSP blocks them.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function PluginSandboxPage() {
|
||||||
|
return <SandboxRuntime />;
|
||||||
|
}
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { JmapRedirectError, fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/account/stalwart/jmap
|
* POST /api/account/stalwart/jmap
|
||||||
@@ -23,14 +24,26 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.text();
|
const body = await request.text();
|
||||||
|
|
||||||
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
const directUrl = `${creds.serverUrl}/jmap/`;
|
||||||
method: 'POST',
|
let response = await postJmap(directUrl, creds.authHeader, body);
|
||||||
headers: {
|
|
||||||
'Authorization': creds.authHeader,
|
if (response.status === 404) {
|
||||||
'Content-Type': 'application/json',
|
// `${serverUrl}/jmap/` is not the API endpoint on this deployment
|
||||||
},
|
// (path prefix, non-Stalwart URL layout). Resolve the session's
|
||||||
body,
|
// advertised apiUrl on the same host and retry once.
|
||||||
});
|
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
|
||||||
|
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
|
||||||
|
if (apiUrl && apiUrl !== directUrl) {
|
||||||
|
response = await postJmap(apiUrl, creds.authHeader, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn('Stalwart JMAP passthrough upstream error', {
|
||||||
|
status: response.status,
|
||||||
|
serverUrl: creds.serverUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
return new NextResponse(responseText, {
|
return new NextResponse(responseText, {
|
||||||
@@ -38,9 +51,27 @@ export async function POST(request: NextRequest) {
|
|||||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof JmapRedirectError) {
|
||||||
|
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||||
|
}
|
||||||
|
// `fetch failed` from undici is too generic to debug — the real reason
|
||||||
|
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
|
||||||
|
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||||
logger.error('Stalwart JMAP passthrough error', {
|
logger.error('Stalwart JMAP passthrough error', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown',
|
error: err?.message ?? 'Unknown',
|
||||||
|
causeCode: err?.cause?.code,
|
||||||
|
causeMessage: err?.cause?.message,
|
||||||
});
|
});
|
||||||
|
// The server this process failed to reach is the user's own mail server,
|
||||||
|
// so the reason is worth surfacing: an opaque 500 leaves operators with
|
||||||
|
// nothing to act on.
|
||||||
|
if (err?.cause?.code) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Cannot reach the JMAP server (${err.cause.code})` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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 { getEntitlementState, setSeatTotal, revokeSeat, readMeteringLedger } from '@/lib/ai/entitlement';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only data endpoints for the `server` AI class's real entitlement
|
||||||
|
* enforcement (lib/ai/entitlement.ts). This is the data plumbing only — the
|
||||||
|
* visual admin console (docs/AI-ASSISTANT-CONCEPT.md §6) is a separate,
|
||||||
|
* not-yet-built UI on top of these same endpoints.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [state, ledger] = await Promise.all([getEntitlementState(), readMeteringLedger()]);
|
||||||
|
return NextResponse.json({ ...state, recentUsage: ledger }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ai entitlement read error', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
let body: { seatsTotal?: unknown; revokeUsername?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof body.seatsTotal === 'number') {
|
||||||
|
const state = await setSeatTotal(body.seatsTotal);
|
||||||
|
await auditLog('ai.entitlement.seats_total', { seatsTotal: state.seatsTotal }, ip);
|
||||||
|
return NextResponse.json(state);
|
||||||
|
}
|
||||||
|
if (typeof body.revokeUsername === 'string' && body.revokeUsername) {
|
||||||
|
const state = await revokeSeat(body.revokeUsername);
|
||||||
|
await auditLog('ai.entitlement.revoke_seat', { username: body.revokeUsername }, ip);
|
||||||
|
return NextResponse.json(state);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'seatsTotal or revokeUsername is required' }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ai entitlement update error', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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 { AiConsoleConfig, AiClass } from '@/lib/ai/types';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const VALID_CLASSES: AiClass[] = ['local', 'server', 'public'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET/PUT /api/admin/ai/policy - the admin console's writable config
|
||||||
|
* (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6): per-class enable, model/
|
||||||
|
* provider allow-lists, retrieval on/off, BYOK consent text. Separate from
|
||||||
|
* /api/admin/ai/entitlement (seats/ledger - runtime state) and from the
|
||||||
|
* generic /api/admin/policy (FeatureGates - the master aiAssistantEnabled
|
||||||
|
* toggle stays there, this console only links to it, per spec §6 open
|
||||||
|
* question 3).
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
return NextResponse.json(configManager.getAiConsoleConfig(), { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ai console policy read error', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(body: Partial<AiConsoleConfig>): string | null {
|
||||||
|
if (body.classesEnabled !== undefined) {
|
||||||
|
if (typeof body.classesEnabled !== 'object' || body.classesEnabled === null) return 'classesEnabled must be an object';
|
||||||
|
for (const key of Object.keys(body.classesEnabled)) {
|
||||||
|
if (!VALID_CLASSES.includes(key as AiClass)) return `classesEnabled has an unknown class "${key}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.serverModelAllowlist !== undefined && body.serverModelAllowlist !== null) {
|
||||||
|
if (!Array.isArray(body.serverModelAllowlist) || !body.serverModelAllowlist.every((m) => typeof m === 'string')) {
|
||||||
|
return 'serverModelAllowlist must be an array of strings or null';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.publicProviderAllowlist !== undefined && body.publicProviderAllowlist !== null) {
|
||||||
|
if (!Array.isArray(body.publicProviderAllowlist) || !body.publicProviderAllowlist.every((m) => typeof m === 'string')) {
|
||||||
|
return 'publicProviderAllowlist must be an array of strings or null';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') {
|
||||||
|
return 'retrievalEnabled must be a boolean';
|
||||||
|
}
|
||||||
|
if (body.consent !== undefined && body.consent !== null) {
|
||||||
|
if (typeof body.consent !== 'object' || typeof body.consent.version !== 'string' || typeof body.consent.text !== 'string') {
|
||||||
|
return 'consent must be { version: string, text: string } or null';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
let body: Partial<AiConsoleConfig>;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validate(body);
|
||||||
|
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const next = await configManager.setAiConsoleConfig(body);
|
||||||
|
await auditLog('ai.console_policy.update', {
|
||||||
|
classesEnabled: next.classesEnabled,
|
||||||
|
retrievalEnabled: next.retrievalEnabled,
|
||||||
|
consentVersion: next.consent?.version ?? null,
|
||||||
|
serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null,
|
||||||
|
publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null,
|
||||||
|
}, ip);
|
||||||
|
return NextResponse.json(next);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ai console policy update error', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { logger } from '@/lib/logger';
|
|||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||||
|
|||||||
Binary file not shown.
+162
-34
@@ -3,8 +3,13 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
|||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { getConfigDir } from '@/lib/admin/paths';
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
|
import {
|
||||||
|
parseDomainBranding,
|
||||||
|
type DomainBrandingEntry,
|
||||||
|
type BrandingOverrideKey,
|
||||||
|
} from '@/lib/admin/domain-branding';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@@ -21,45 +26,127 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||||||
'image/vnd.microsoft.icon',
|
'image/vnd.microsoft.icon',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
type UploadSlot = BrandingOverrideKey;
|
||||||
|
|
||||||
/** Slots that correspond to branding config keys */
|
/** Slots that correspond to branding config keys */
|
||||||
const VALID_SLOTS = new Set([
|
const VALID_SLOTS = new Set<UploadSlot>([
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
|
'pwaIconUrl',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
'appLogoDarkUrl',
|
'appLogoDarkUrl',
|
||||||
'loginLogoLightUrl',
|
'loginLogoLightUrl',
|
||||||
'loginLogoDarkUrl',
|
'loginLogoDarkUrl',
|
||||||
|
'pwaScreenshotMobileUrl',
|
||||||
|
'pwaScreenshotDesktopUrl',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const EXT_BY_MIME: 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 POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico'];
|
||||||
|
|
||||||
|
// Exact hostnames only (no wildcards): wildcards can't be uploaded against
|
||||||
|
// because we'd need a real subdomain to serve the file from.
|
||||||
|
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||||
|
|
||||||
function sanitizeFilename(name: string): string {
|
function sanitizeFilename(name: string): string {
|
||||||
// Strip directory traversal, keep only safe chars
|
// Strip directory traversal, keep only safe chars
|
||||||
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHost(raw: string): string {
|
||||||
|
return raw.trim().toLowerCase().replace(/\.+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filename used to store a per-host uploaded asset. */
|
||||||
|
function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string {
|
||||||
|
return sanitizeFilename(`domain__${host}__${slot}${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the file belongs to the given host+slot (any extension). */
|
||||||
|
function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean {
|
||||||
|
const prefix = sanitizeFilename(`domain__${host}__${slot}.`);
|
||||||
|
return filename.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge a per-host update into the existing domainBranding array. */
|
||||||
|
function mergeDomainEntry(
|
||||||
|
current: DomainBrandingEntry[],
|
||||||
|
host: string,
|
||||||
|
patch: Partial<DomainBrandingEntry>,
|
||||||
|
): DomainBrandingEntry[] {
|
||||||
|
const next = current.slice();
|
||||||
|
const idx = next.findIndex(e => e.host === host);
|
||||||
|
if (idx === -1) {
|
||||||
|
next.push({ host, ...patch });
|
||||||
|
} else {
|
||||||
|
next[idx] = { ...next[idx], ...patch };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove keys from a host's entry. If the entry has nothing left besides
|
||||||
|
* `host`, drop it entirely. */
|
||||||
|
function clearDomainKeys(
|
||||||
|
current: DomainBrandingEntry[],
|
||||||
|
host: string,
|
||||||
|
keys: BrandingOverrideKey[],
|
||||||
|
): DomainBrandingEntry[] {
|
||||||
|
const idx = current.findIndex(e => e.host === host);
|
||||||
|
if (idx === -1) return current;
|
||||||
|
const entry = { ...current[idx] };
|
||||||
|
for (const key of keys) delete (entry as Record<string, unknown>)[key];
|
||||||
|
const next = current.slice();
|
||||||
|
if (Object.keys(entry).filter(k => k !== 'host').length === 0) {
|
||||||
|
next.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
next[idx] = entry;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/branding - Upload a branding image file
|
* POST /api/admin/branding - Upload a branding image file
|
||||||
*
|
*
|
||||||
* Expects multipart/form-data with:
|
* Expects multipart/form-data with:
|
||||||
* - file: the image file
|
* - file: the image file
|
||||||
* - slot: which branding field this is for (e.g. "faviconUrl")
|
* - slot: which branding field this is for (e.g. "faviconUrl")
|
||||||
|
* - host (optional): when set, the upload is stored against the
|
||||||
|
* per-domain entry for that hostname instead of the global default.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('file') as File | null;
|
const file = formData.get('file') as File | null;
|
||||||
const slot = formData.get('slot') as string | null;
|
const slot = formData.get('slot') as string | null;
|
||||||
|
const rawHost = (formData.get('host') as string | null) ?? '';
|
||||||
|
|
||||||
if (!file || !slot) {
|
if (!file || !slot) {
|
||||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_SLOTS.has(slot)) {
|
if (!VALID_SLOTS.has(slot as UploadSlot)) {
|
||||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||||
|
if (host && !EXACT_HOST_RE.test(host)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -71,34 +158,51 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine extension from mime type
|
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||||
const extMap: Record<string, string> = {
|
const safeName = host
|
||||||
'image/svg+xml': '.svg',
|
? domainAssetName(host, slot as BrandingOverrideKey, ext)
|
||||||
'image/png': '.png',
|
: sanitizeFilename(`${slot}${ext}`);
|
||||||
'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(getBrandingDir(), safeName);
|
const filePath = path.join(getBrandingDir(), safeName);
|
||||||
|
|
||||||
// Ensure branding directory exists
|
|
||||||
if (!existsSync(getBrandingDir())) {
|
if (!existsSync(getBrandingDir())) {
|
||||||
await mkdir(getBrandingDir(), { recursive: true });
|
await mkdir(getBrandingDir(), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write file to disk
|
// Strip any prior asset for the same slot but a different extension so
|
||||||
|
// the directory doesn't accumulate orphan files on re-upload.
|
||||||
|
const dir = getBrandingDir();
|
||||||
|
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||||
|
for (const f of allFiles) {
|
||||||
|
if (f === safeName) continue;
|
||||||
|
const isSame = host
|
||||||
|
? isDomainAssetFor(f, host, slot as BrandingOverrideKey)
|
||||||
|
: POSSIBLE_EXTS.some(e => f === `${slot}${e}`);
|
||||||
|
if (isSame) {
|
||||||
|
try { await unlink(path.join(dir, f)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await writeFile(filePath, buffer);
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
// Update config to point to the served URL
|
|
||||||
const servedUrl = `/api/admin/branding/${safeName}`;
|
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
|
||||||
|
|
||||||
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
|
if (host) {
|
||||||
|
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||||
|
const next = mergeDomainEntry(current, host, { [slot]: servedUrl });
|
||||||
|
await configManager.setAdminConfig({ domainBranding: next });
|
||||||
|
} else {
|
||||||
|
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('branding_upload', {
|
||||||
|
slot,
|
||||||
|
host: host || undefined,
|
||||||
|
filename: safeName,
|
||||||
|
size: file.size,
|
||||||
|
mimeType: file.type,
|
||||||
|
}, ip);
|
||||||
|
|
||||||
return NextResponse.json({ url: servedUrl, filename: safeName });
|
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -110,36 +214,60 @@ export async function POST(request: NextRequest) {
|
|||||||
/**
|
/**
|
||||||
* DELETE /api/admin/branding - Remove an uploaded branding file
|
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||||
*
|
*
|
||||||
* Expects JSON body: { slot: string }
|
* Expects JSON body: { slot: string, host?: string }
|
||||||
|
*
|
||||||
|
* When `host` is provided, only the per-domain asset for that host+slot is
|
||||||
|
* removed (and the override in `domainBranding[host][slot]` is cleared).
|
||||||
|
* Otherwise the global asset and config override are removed.
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
const { slot } = await request.json();
|
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
|
||||||
|
const slot = body.slot;
|
||||||
|
const rawHost = body.host ?? '';
|
||||||
|
|
||||||
if (!slot || !VALID_SLOTS.has(slot)) {
|
if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
|
||||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and remove matching files for this slot
|
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
if (host && !EXACT_HOST_RE.test(host)) {
|
||||||
|
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = getBrandingDir();
|
||||||
let removed = false;
|
let removed = false;
|
||||||
for (const ext of possibleExts) {
|
if (host) {
|
||||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||||
if (existsSync(filePath)) {
|
for (const f of allFiles) {
|
||||||
await unlink(filePath);
|
if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) {
|
||||||
removed = true;
|
try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const ext of POSSIBLE_EXTS) {
|
||||||
|
const filePath = path.join(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.ensureLoaded();
|
||||||
await configManager.removeAdminOverride(slot);
|
if (host) {
|
||||||
|
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||||
|
const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]);
|
||||||
|
await configManager.setAdminConfig({ domainBranding: next });
|
||||||
|
} else {
|
||||||
|
await configManager.removeAdminOverride(slot);
|
||||||
|
}
|
||||||
|
|
||||||
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
|
await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -2,22 +2,46 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
|
import { parseDomainBranding } from '@/lib/admin/domain-branding';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Strings that count as "no real secret configured" - used so the dashboard
|
||||||
|
// can warn about a placeholder session secret without us ever returning the
|
||||||
|
// raw value to the client.
|
||||||
|
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/config - Get full config with sources (admin-protected)
|
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||||
|
*
|
||||||
|
* Sensitive keys (sessionSecret, oauthClientSecret) are returned with
|
||||||
|
* `value` omitted and a `hasValue` boolean instead. An admin session is
|
||||||
|
* enough to read every other config knob; the secrets themselves stay on
|
||||||
|
* the server so that an XSS or session-theft can't lift them in one
|
||||||
|
* request and forge admin/user session cookies offline.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const config = configManager.getAllWithSources();
|
const config = configManager.getAllWithSources();
|
||||||
|
|
||||||
return NextResponse.json(config, {
|
const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
|
||||||
|
for (const [key, entry] of Object.entries(config)) {
|
||||||
|
if (SENSITIVE_CONFIG_KEYS.has(key)) {
|
||||||
|
const v = entry.value;
|
||||||
|
const hasValue =
|
||||||
|
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
|
||||||
|
safe[key] = { source: entry.source, hasValue };
|
||||||
|
} else {
|
||||||
|
safe[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(safe, {
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -31,7 +55,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -65,6 +89,25 @@ export async function PATCH(request: NextRequest) {
|
|||||||
updates.jmapServers = sanitized;
|
updates.jmapServers = sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize domainBranding: drop entries with an invalid/missing host or
|
||||||
|
// duplicate hosts before persisting. Each entry's branding field strings
|
||||||
|
// are passed through unchanged (URL/string content is the operator's
|
||||||
|
// responsibility, same as the flat branding fields).
|
||||||
|
if ('domainBranding' in updates) {
|
||||||
|
const incoming = updates.domainBranding;
|
||||||
|
if (incoming != null && !Array.isArray(incoming)) {
|
||||||
|
return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const sanitized = parseDomainBranding(incoming);
|
||||||
|
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||||
|
if (sanitized.length !== incomingCount) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).',
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
updates.domainBranding = sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
// Get old values for audit
|
// Get old values for audit
|
||||||
const oldValues: Record<string, unknown> = {};
|
const oldValues: Record<string, unknown> = {};
|
||||||
for (const key of Object.keys(updates)) {
|
for (const key of Object.keys(updates)) {
|
||||||
@@ -86,7 +129,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import {
|
|||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
async function getDirectoryUrl(): Promise<string> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||||
|
|
||||||
@@ -19,17 +23,18 @@ const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
|||||||
* Lets admins audit what they're about to install before pressing the button.
|
* Lets admins audit what they're about to install before pressing the button.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ slug: string }> },
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const directoryUrl = await getDirectoryUrl();
|
||||||
|
|
||||||
// 1. Extension metadata + screenshots + theme previews from the directory
|
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||||
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
|
||||||
const detailRes = await fetch(detailUrl.toString(), {
|
const detailRes = await fetch(detailUrl.toString(), {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
@@ -63,7 +68,7 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const bundleUrl = new URL(
|
const bundleUrl = new URL(
|
||||||
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||||
DIRECTORY_URL,
|
directoryUrl,
|
||||||
);
|
);
|
||||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
@@ -144,14 +149,16 @@ export async function GET(
|
|||||||
getPluginRegistry(),
|
getPluginRegistry(),
|
||||||
getThemeRegistry(),
|
getThemeRegistry(),
|
||||||
]);
|
]);
|
||||||
const installed = type === 'theme'
|
const installedEntry = type === 'theme'
|
||||||
? themeRegistry.themes.some((t) => t.id === slug)
|
? themeRegistry.themes.find((t) => t.id === slug)
|
||||||
: pluginRegistry.plugins.some((p) => p.id === slug);
|
: pluginRegistry.plugins.find((p) => p.id === slug);
|
||||||
|
const installed = installedEntry !== undefined;
|
||||||
|
const installedVersion = installedEntry?.version ?? null;
|
||||||
|
|
||||||
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||||
const screenshots = Array.isArray(extension.screenshots)
|
const screenshots = Array.isArray(extension.screenshots)
|
||||||
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||||
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
|
||||||
altText: s.altText ?? null,
|
altText: s.altText ?? null,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
@@ -170,7 +177,7 @@ export async function GET(
|
|||||||
|
|
||||||
const fileUrl = (path: unknown): string | null =>
|
const fileUrl = (path: unknown): string | null =>
|
||||||
typeof path === 'string' && path
|
typeof path === 'string' && path
|
||||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -206,6 +213,7 @@ export async function GET(
|
|||||||
error: bundleError,
|
error: bundleError,
|
||||||
},
|
},
|
||||||
installed,
|
installed,
|
||||||
|
installedVersion,
|
||||||
},
|
},
|
||||||
{ headers: { 'Cache-Control': 'no-store' } },
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
|
|||||||
import {
|
import {
|
||||||
savePlugin,
|
savePlugin,
|
||||||
saveTheme,
|
saveTheme,
|
||||||
|
getPlugin,
|
||||||
|
getTheme,
|
||||||
getPluginRegistry,
|
getPluginRegistry,
|
||||||
getThemeRegistry,
|
getThemeRegistry,
|
||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
@@ -13,13 +15,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
sanitizeHttpOrigins,
|
sanitizeHttpOrigins,
|
||||||
|
sanitizeApiPostPaths,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
async function getDirectoryUrl(): Promise<string> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
@@ -27,11 +34,12 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions
|
|||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const directoryUrl = await getDirectoryUrl();
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
|
const url = new URL('/api/v1/extensions', directoryUrl);
|
||||||
|
|
||||||
// Forward all search params
|
// Forward all search params
|
||||||
for (const [key, value] of searchParams.entries()) {
|
for (const [key, value] of searchParams.entries()) {
|
||||||
@@ -58,23 +66,32 @@ export async function GET(request: NextRequest) {
|
|||||||
getThemeRegistry(),
|
getThemeRegistry(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
const installedPluginVersions = new Map(
|
||||||
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
pluginRegistry.plugins.map(p => [p.id, p.version] as const),
|
||||||
|
);
|
||||||
|
const installedThemeVersions = new Map(
|
||||||
|
themeRegistry.themes.map(t => [t.id, t.version] as const),
|
||||||
|
);
|
||||||
|
|
||||||
const fileUrl = (path: unknown): string | null =>
|
const fileUrl = (path: unknown): string | null =>
|
||||||
typeof path === 'string' && path
|
typeof path === 'string' && path
|
||||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
data.data = data.data.map((ext: Record<string, unknown>) => {
|
||||||
...ext,
|
const slug = ext.slug as string;
|
||||||
iconUrl: fileUrl(ext.iconPath),
|
const installedVersion = ext.type === 'theme'
|
||||||
bannerUrl: fileUrl(ext.bannerPath),
|
? installedThemeVersions.get(slug) ?? null
|
||||||
installed: ext.type === 'theme'
|
: installedPluginVersions.get(slug) ?? null;
|
||||||
? installedThemes.has(ext.slug as string)
|
return {
|
||||||
: installedPlugins.has(ext.slug as string),
|
...ext,
|
||||||
}));
|
iconUrl: fileUrl(ext.iconPath),
|
||||||
|
bannerUrl: fileUrl(ext.bannerPath),
|
||||||
|
installed: installedVersion !== null,
|
||||||
|
installedVersion,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(data, {
|
return NextResponse.json(data, {
|
||||||
@@ -92,7 +109,7 @@ export async function GET(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -107,7 +124,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download the bundle from the directory
|
// Download the bundle from the directory
|
||||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
|
const directoryUrl = await getDirectoryUrl();
|
||||||
|
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
|
||||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
});
|
});
|
||||||
@@ -163,6 +181,18 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Resolve and strictly validate the id used as a filename. Marketplace
|
||||||
|
// bundles are authored by a third-party publisher; without this an id
|
||||||
|
// like "../../foo" causes savePlugin/saveTheme to write outside the
|
||||||
|
// plugins/themes dir via path.join.
|
||||||
|
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
|
||||||
|
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'theme') {
|
if (type === 'theme') {
|
||||||
// Read theme.css
|
// Read theme.css
|
||||||
const cssFile = zip.file(root + 'theme.css');
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
@@ -181,22 +211,43 @@ export async function POST(request: NextRequest) {
|
|||||||
warnings.push(...sanitized.warnings);
|
warnings.push(...sanitized.warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingTheme = await getTheme(resolvedId);
|
||||||
|
const isUpdate = existingTheme !== null;
|
||||||
|
|
||||||
const theme: ServerTheme = {
|
const theme: ServerTheme = {
|
||||||
id: (manifest.id as string) || slug,
|
id: resolvedId,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
version: (manifest.version as string) || version,
|
// Prefer the directory-published version (what we requested) over
|
||||||
|
// manifest.version. Publishers sometimes forget to bump the version
|
||||||
|
// inside the bundle's manifest.json; trusting it would make the
|
||||||
|
// update never appear to "stick" — the registry would keep showing
|
||||||
|
// the older version even after a successful update.
|
||||||
|
version: version || (manifest.version as string),
|
||||||
author: (manifest.author as string) || 'Unknown',
|
author: (manifest.author as string) || 'Unknown',
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||||
enabled: true,
|
enabled: existingTheme?.enabled ?? true,
|
||||||
installedAt: now,
|
...(existingTheme?.forceEnabled !== undefined
|
||||||
|
? { forceEnabled: existingTheme.forceEnabled }
|
||||||
|
: {}),
|
||||||
|
installedAt: existingTheme?.installedAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await saveTheme(theme, css);
|
await saveTheme(theme, css);
|
||||||
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
|
await auditLog(
|
||||||
|
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
|
||||||
|
{
|
||||||
|
id: theme.id,
|
||||||
|
name: theme.name,
|
||||||
|
version: theme.version,
|
||||||
|
slug,
|
||||||
|
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
|
||||||
|
},
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, theme, warnings });
|
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
|
||||||
} else {
|
} else {
|
||||||
// Plugin installation
|
// Plugin installation
|
||||||
// Read entrypoint JS
|
// Read entrypoint JS
|
||||||
@@ -266,31 +317,74 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||||
|
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
|
||||||
|
? (manifest.apiPostPaths as unknown[]).filter(
|
||||||
|
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (droppedApiPostPaths.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPlugin = await getPlugin(resolvedId);
|
||||||
|
const isUpdate = existingPlugin !== null;
|
||||||
|
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id: (manifest.id as string) || slug,
|
id: resolvedId,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
version: (manifest.version as string) || version,
|
// See theme branch: trust the directory-published version, not
|
||||||
|
// manifest.version, so updates actually stick in the registry.
|
||||||
|
version: version || (manifest.version as string),
|
||||||
author: (manifest.author as string) || 'Unknown',
|
author: (manifest.author as string) || 'Unknown',
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: (manifest.type as string) || 'hook',
|
type: (manifest.type as string) || 'hook',
|
||||||
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||||
permissions,
|
permissions,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
enabled: true,
|
enabled: existingPlugin?.enabled ?? true,
|
||||||
installedAt: now,
|
...(existingPlugin?.forceEnabled !== undefined
|
||||||
|
? { forceEnabled: existingPlugin.forceEnabled }
|
||||||
|
: {}),
|
||||||
|
installedAt: existingPlugin?.installedAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
...(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
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
...(declaredHttpOrigins.length > 0
|
...(declaredHttpOrigins.length > 0
|
||||||
? { httpOrigins: declaredHttpOrigins }
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredApiPostPaths.length > 0
|
||||||
|
? { apiPostPaths: declaredApiPostPaths }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
await auditLog(
|
||||||
|
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
|
||||||
|
{
|
||||||
|
id: plugin.id,
|
||||||
|
name: plugin.name,
|
||||||
|
version: plugin.version,
|
||||||
|
slug,
|
||||||
|
frameOrigins: declaredFrameOrigins,
|
||||||
|
httpOrigins: declaredHttpOrigins,
|
||||||
|
apiPostPaths: declaredApiPostPaths,
|
||||||
|
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
|
||||||
|
},
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, plugin, warnings });
|
return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function isValidOriginUrl(value: string): boolean {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(request);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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 { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
|
||||||
|
*
|
||||||
|
* GET /api/admin/plugin-approvals → list all entries
|
||||||
|
* POST /api/admin/plugin-approvals → { pluginId, bundleHash, decision: 'approved'|'denied' }
|
||||||
|
* DELETE /api/admin/plugin-approvals?pluginId=…&bundleHash=… → revoke
|
||||||
|
*/
|
||||||
|
|
||||||
|
function isValidId(s: unknown): s is string {
|
||||||
|
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
|
||||||
|
}
|
||||||
|
function isValidHash(s: unknown): s is string {
|
||||||
|
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
const entries = await listApprovals();
|
||||||
|
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||||
|
// for the audit trail rather than a per-user identity.
|
||||||
|
const adminUser = 'admin';
|
||||||
|
void result;
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try { body = await request.json(); } catch { body = null; }
|
||||||
|
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown };
|
||||||
|
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||||
|
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (b.decision !== 'approved' && b.decision !== 'denied') {
|
||||||
|
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
|
||||||
|
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
|
||||||
|
return NextResponse.json({ entry });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||||
|
// for the audit trail rather than a per-user identity.
|
||||||
|
const adminUser = 'admin';
|
||||||
|
void result;
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
const pluginId = request.nextUrl.searchParams.get('pluginId');
|
||||||
|
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
|
||||||
|
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
|
||||||
|
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||||
|
}
|
||||||
|
await revokeApproval(pluginId, bundleHash);
|
||||||
|
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||||
|
import { signBytes } from '@/lib/admin/plugin-signing';
|
||||||
|
|
||||||
|
async function safeSign(code: string): Promise<string | null> {
|
||||||
|
try { return await signBytes(code); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
@@ -25,14 +30,15 @@ export async function GET(
|
|||||||
const devEntry = await getDevPlugin(id);
|
const devEntry = await getDevPlugin(id);
|
||||||
if (devEntry) {
|
if (devEntry) {
|
||||||
const code = await readDevBundle(devEntry);
|
const code = await readDevBundle(devEntry);
|
||||||
return new NextResponse(code, {
|
const signature = await safeSign(code);
|
||||||
headers: {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/javascript; charset=utf-8',
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||||
},
|
};
|
||||||
});
|
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||||
|
return new NextResponse(code, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = await getPlugin(id);
|
const plugin = await getPlugin(id);
|
||||||
@@ -59,6 +65,9 @@ export async function GET(
|
|||||||
};
|
};
|
||||||
if (etag) headers['ETag'] = etag;
|
if (etag) headers['ETag'] = etag;
|
||||||
|
|
||||||
|
const signature = await safeSign(code);
|
||||||
|
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||||
|
|
||||||
if (etag && request.headers.get('if-none-match') === etag) {
|
if (etag && request.headers.get('if-none-match') === etag) {
|
||||||
return new NextResponse(null, { status: 304, headers });
|
return new NextResponse(null, { status: 304, headers });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminAuth = await requireAdminAuth();
|
const adminAuth = await requireAdminAuth(request);
|
||||||
const isAdmin = !('error' in adminAuth);
|
const isAdmin = !('error' in adminAuth);
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
@@ -51,13 +51,18 @@ export async function GET(
|
|||||||
|
|
||||||
const config = await getPluginConfig(id);
|
const config = await getPluginConfig(id);
|
||||||
|
|
||||||
let response: Record<string, unknown> = config;
|
let response: Record<string, unknown>;
|
||||||
if (!isAdmin && plugin.configSchema) {
|
if (isAdmin) {
|
||||||
|
response = config;
|
||||||
|
} else {
|
||||||
response = {};
|
response = {};
|
||||||
for (const [key, value] of Object.entries(config)) {
|
const schema = plugin.configSchema;
|
||||||
const field = plugin.configSchema[key];
|
if (schema) {
|
||||||
if (field?.type === 'secret') continue;
|
for (const [key, value] of Object.entries(config)) {
|
||||||
response[key] = value;
|
const field = schema[key];
|
||||||
|
if (!field || field.type === 'secret') continue;
|
||||||
|
response[key] = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +85,7 @@ export async function PUT(
|
|||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -110,6 +115,13 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plugin.configSchema && !plugin.configSchema[body.key]) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Key is not declared in the plugin configSchema' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await setPluginConfig(id, body.key, body.value);
|
await setPluginConfig(id, body.key, body.value);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -127,7 +139,7 @@ export async function DELETE(
|
|||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
|||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
sanitizeHttpOrigins,
|
sanitizeHttpOrigins,
|
||||||
|
sanitizeApiPostPaths,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
@@ -31,9 +32,9 @@ const SUSPICIOUS_JS_PATTERNS = [
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins - List all admin-managed plugins
|
* GET /api/admin/plugins - List all admin-managed plugins
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const [registry, devEntries] = await Promise.all([
|
const [registry, devEntries] = await Promise.all([
|
||||||
@@ -63,7 +64,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -157,21 +158,54 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const code = await entryFile.async('string');
|
const code = await entryFile.async('string');
|
||||||
|
|
||||||
// Security: block plugins containing dangerous JS patterns
|
// Security: scan for dangerous JS patterns across EVERY script in the
|
||||||
const warnings: string[] = [];
|
// bundle, not just the entrypoint - a second .js file was previously never
|
||||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
// looked at.
|
||||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
//
|
||||||
pattern.lastIndex = 0;
|
// The result is a reviewable finding rather than an unconditional reject.
|
||||||
|
// Minified crypto libraries (openpgp.js, pkijs) legitimately contain these
|
||||||
|
// patterns, so a hard block makes S/MIME and PGP plugins uninstallable.
|
||||||
|
// This route is already admin-authenticated, so the scan is defence in
|
||||||
|
// depth against an accidental or compromised upload, not a trust boundary:
|
||||||
|
// an admin may proceed with `overrideWarnings`, and the override is
|
||||||
|
// recorded in the audit log with the exact findings.
|
||||||
|
const findings: Array<{ file: string; patterns: string[] }> = [];
|
||||||
|
for (const [filePath, entry] of Object.entries(zip.files)) {
|
||||||
|
if (entry.dir) continue;
|
||||||
|
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
|
||||||
|
if (ext !== '.js' && ext !== '.mjs') continue;
|
||||||
|
const source = filePath === root + (manifest.entrypoint as string)
|
||||||
|
? code
|
||||||
|
: await entry.async('string');
|
||||||
|
const hits: string[] = [];
|
||||||
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||||
|
if (pattern.test(source)) hits.push(label);
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
if (hits.length > 0) {
|
||||||
|
findings.push({ file: filePath.slice(root.length), patterns: hits });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (warnings.length > 0) {
|
|
||||||
|
const overrideWarnings = formData.get('overrideWarnings') === 'true';
|
||||||
|
if (findings.length > 0 && !overrideWarnings) {
|
||||||
|
const summary = findings
|
||||||
|
.map(f => `${f.file}: ${f.patterns.join(', ')}`)
|
||||||
|
.join('; ');
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
{
|
||||||
|
error: `Plugin rejected: ${summary}. Review the bundle; if these are expected `
|
||||||
|
+ `(e.g. a vendored crypto library), re-upload with "overrideWarnings" to proceed.`,
|
||||||
|
findings,
|
||||||
|
canOverride: true,
|
||||||
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||||
|
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
@@ -181,6 +215,7 @@ export async function POST(request: NextRequest) {
|
|||||||
author: manifest.author as string,
|
author: manifest.author as string,
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: manifest.type as string,
|
type: manifest.type as string,
|
||||||
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||||
permissions: (manifest.permissions as string[]) || [],
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
entrypoint: manifest.entrypoint as string,
|
entrypoint: manifest.entrypoint as string,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -190,21 +225,39 @@ export async function POST(request: NextRequest) {
|
|||||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(manifest.locales && typeof manifest.locales === 'object'
|
||||||
|
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||||
|
: {}),
|
||||||
...(declaredFrameOrigins.length > 0
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
...(declaredHttpOrigins.length > 0
|
...(declaredHttpOrigins.length > 0
|
||||||
? { httpOrigins: declaredHttpOrigins }
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredApiPostPaths.length > 0
|
||||||
|
? { apiPostPaths: declaredApiPostPaths }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||||
|
if (findings.length > 0) {
|
||||||
|
// Record WHAT was waved through, not merely that an override happened -
|
||||||
|
// otherwise the audit trail can't answer "which patterns did we accept?".
|
||||||
|
await auditLog(
|
||||||
|
'plugin.install.scan_override',
|
||||||
|
{ id: plugin.id, version: plugin.version, findings },
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
logger.warn('Plugin installed with scanner override', { id: plugin.id, findings });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ plugin });
|
// Echo accepted findings back so the admin UI can confirm exactly what was
|
||||||
|
// waved through, rather than reporting a bare success.
|
||||||
|
return NextResponse.json(findings.length > 0 ? { plugin, findings } : { plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
@@ -217,7 +270,7 @@ export async function POST(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -235,9 +288,18 @@ export async function PATCH(request: NextRequest) {
|
|||||||
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
const updated = await updatePluginMeta(id, updates);
|
let updated = await updatePluginMeta(id, updates);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
// Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
|
||||||
|
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
|
||||||
|
// client. Skip the registry write and return the live dev plugin so the
|
||||||
|
// policy save path can proceed.
|
||||||
|
const devEntries = await listDevPlugins();
|
||||||
|
const devEntry = devEntries.find(e => e.plugin.id === id);
|
||||||
|
if (!devEntry) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
updated = { ...devEntry.plugin, ...updates };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable/disable changes the set of plugins contributing frame origins.
|
// Enable/disable changes the set of plugins contributing frame origins.
|
||||||
@@ -259,7 +321,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import {
|
|||||||
* Returns current consent + endpoint + next/last send + a live preview
|
* Returns current consent + endpoint + next/last send + a live preview
|
||||||
* of exactly what the next heartbeat would contain.
|
* of exactly what the next heartbeat would contain.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(request);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
const { consent, source, state } = await effectiveConsent();
|
const { consent, source, state } = await effectiveConsent();
|
||||||
@@ -61,7 +61,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(request);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/themes - List all admin-managed themes
|
* GET /api/admin/themes - List all admin-managed themes
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const registry = await getThemeRegistry();
|
const registry = await getThemeRegistry();
|
||||||
@@ -36,7 +36,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
* GET /api/admin/version
|
* GET /api/admin/version
|
||||||
* Returns the cached update status, last check times, and effective config.
|
* Returns the cached update status, last check times, and effective config.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(request);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
const state = await loadState();
|
const state = await loadState();
|
||||||
@@ -47,7 +47,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(req);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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 {
|
||||||
|
getVncDirectoryConfig,
|
||||||
|
saveVncDirectoryConfig,
|
||||||
|
DEFAULT_VNCDIRECTORY_CONFIG,
|
||||||
|
VNCDIRECTORY_SENSITIVE_KEYS,
|
||||||
|
type VncDirectoryConfig,
|
||||||
|
} from '@/lib/admin/vncdirectory-config';
|
||||||
|
|
||||||
|
const VALID_LDAP_TYPES = new Set(['openldap', 'ms-ad']);
|
||||||
|
const KNOWN_KEYS = new Set(Object.keys(DEFAULT_VNCDIRECTORY_CONFIG));
|
||||||
|
|
||||||
|
function maskConfigForClient(config: VncDirectoryConfig): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (VNCDIRECTORY_SENSITIVE_KEYS.has(key)) {
|
||||||
|
result[key] = typeof value === 'string' && value.length > 0 ? '••••••' : '';
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const config = await getVncDirectoryConfig();
|
||||||
|
return NextResponse.json(maskConfigForClient(config), {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VNCdirectory config read error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAdminAuth(request);
|
||||||
|
if ('error' in authResult) return authResult.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||||
|
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate known keys only
|
||||||
|
const unknownKeys = Object.keys(body).filter((k) => !KNOWN_KEYS.has(k));
|
||||||
|
if (unknownKeys.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unknown config keys: ${unknownKeys.join(', ')}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate boolean fields
|
||||||
|
const boolFields = ['enabled', 'samlEnabled', 'ldapEnabled', 'tfaEnabled', 'oidcEnabled'];
|
||||||
|
for (const key of boolFields) {
|
||||||
|
if (key in body && typeof body[key] !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `${key} must be a boolean` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sessionTtl
|
||||||
|
if ('sessionTtl' in body) {
|
||||||
|
const ttl = Number(body.sessionTtl);
|
||||||
|
if (!Number.isFinite(ttl) || ttl < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'sessionTtl must be a non-negative number' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
body.sessionTtl = ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ldapType
|
||||||
|
if ('ldapType' in body && !VALID_LDAP_TYPES.has(body.ldapType)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid ldapType: ${body.ldapType}. Must be 'openldap' or 'ms-ad'.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate federatedApps
|
||||||
|
if ('federatedApps' in body) {
|
||||||
|
if (!body.federatedApps || typeof body.federatedApps !== 'object' || Array.isArray(body.federatedApps)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'federatedApps must be an object mapping app names to URLs' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [appName, url] of Object.entries(body.federatedApps as Record<string, unknown>)) {
|
||||||
|
if (typeof url !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `federatedApps.${appName} must be a string URL` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If apiKey or ldapBindPassword are "••••••", preserve existing value
|
||||||
|
const currentConfig = await getVncDirectoryConfig();
|
||||||
|
if (body.apiKey === '••••••') {
|
||||||
|
body.apiKey = currentConfig.apiKey;
|
||||||
|
}
|
||||||
|
if (body.ldapBindPassword === '••••••') {
|
||||||
|
body.ldapBindPassword = currentConfig.ldapBindPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedKeys = Object.keys(body).filter((k) => {
|
||||||
|
const currentVal = currentConfig[k as keyof VncDirectoryConfig];
|
||||||
|
const newVal = body[k];
|
||||||
|
if (k === 'federatedApps') {
|
||||||
|
return JSON.stringify(currentVal) !== JSON.stringify(newVal);
|
||||||
|
}
|
||||||
|
return String(currentVal ?? '') !== String(newVal ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveVncDirectoryConfig(body as Partial<VncDirectoryConfig>);
|
||||||
|
|
||||||
|
if (changedKeys.length > 0) {
|
||||||
|
await auditLog('vncdirectory.update', { changedKeys }, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VNCdirectory config update error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { findOpencodeServer, parseModelRef, opencodePrompt } from '@/lib/ai/opencode';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 200 * 1024;
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/opencode/chat — one-shot chat against a locally-running
|
||||||
|
* `opencode serve`.
|
||||||
|
*
|
||||||
|
* Deliberately NOT entitlement-metered, unlike /api/ai/server/chat: this runs
|
||||||
|
* on the user's own machine against provider credentials opencode itself
|
||||||
|
* holds, so there is no centrally-borne cost for this app to bill — the same
|
||||||
|
* reasoning that leaves `local` unmetered (lib/ai/entitlement.ts's header).
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await getStalwartCredentials(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
|
||||||
|
return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await request.text();
|
||||||
|
if (rawBody.length > MAX_BODY_BYTES) {
|
||||||
|
return NextResponse.json({ error: 'request too large' }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { model?: unknown; messages?: unknown };
|
||||||
|
try {
|
||||||
|
body = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = typeof body.model === 'string' ? body.model : '';
|
||||||
|
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
|
||||||
|
if (!model || !messages || messages.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await findOpencodeServer();
|
||||||
|
if (!found) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed \u2014 install it from opencode.ai, then restart VNCmail+.' },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!found.models.some((m) => m.ref === model)) {
|
||||||
|
// The picker is populated from this same list, so a mismatch means the
|
||||||
|
// saved model was removed/renamed in opencode since it was chosen -
|
||||||
|
// clearer to say so than to forward it and surface opencode's own error.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `OpenCode no longer offers the model "${model}" \u2014 pick another in Settings.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parsed = parseModelRef(model);
|
||||||
|
if (!parsed) {
|
||||||
|
return NextResponse.json({ error: `Malformed model reference "${model}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten our chat-messages shape onto opencode's (system field + text
|
||||||
|
// parts). Every non-system message is already just the built prompt.
|
||||||
|
const system = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n') || undefined;
|
||||||
|
const userText = messages.filter((m) => m.role !== 'system').map((m) => m.content).join('\n\n');
|
||||||
|
if (!userText.trim()) {
|
||||||
|
return NextResponse.json({ error: 'no user content to send' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await opencodePrompt(found.baseUrl, parsed, system, userText);
|
||||||
|
if (!result.ok) {
|
||||||
|
logger.error('opencode prompt failed', { error: result.error });
|
||||||
|
return NextResponse.json({ error: result.error }, { status: 502 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ answer: result.answer });
|
||||||
|
} catch (cause) {
|
||||||
|
logger.error('opencode chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||||
|
return NextResponse.json({ error: 'OpenCode server unreachable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { findOpencodeServer } from '@/lib/ai/opencode';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/opencode/models — models a locally-running `opencode serve`
|
||||||
|
* exposes. Proxied rather than fetched directly by the renderer: the desktop
|
||||||
|
* shell's origin is a random localhost port that changes every launch, so a
|
||||||
|
* direct call would need opencode's CORS allowlist updated each time.
|
||||||
|
*
|
||||||
|
* Listing is not a billable action, so a valid session is enough — no seat
|
||||||
|
* check (matching /api/ai/server/models).
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await getStalwartCredentials(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
|
||||||
|
return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await findOpencodeServer();
|
||||||
|
if (!found) {
|
||||||
|
// 503 not 500: "nothing is listening" is a normal state (opencode simply
|
||||||
|
// isn't running), and the client turns it into setup guidance rather than
|
||||||
|
// an error banner.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed \u2014 install it from opencode.ai, then restart VNCmail+.' },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ models: found.models.map((m) => ({ ref: m.ref, label: m.label })) },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import {
|
||||||
|
findOpencodeServer, listOpencodeProviders, setOpencodeProviderKey, removeOpencodeProvider,
|
||||||
|
} from '@/lib/ai/opencode';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const SETUP_ERROR =
|
||||||
|
'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed — install it from opencode.ai, then restart VNCmail+.';
|
||||||
|
|
||||||
|
async function requireOpencode(request: NextRequest) {
|
||||||
|
const auth = await getStalwartCredentials(request);
|
||||||
|
if (!auth) return { error: NextResponse.json({ error: 'not authenticated' }, { status: 401 }) } as const;
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
|
||||||
|
return { error: NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 }) } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await findOpencodeServer();
|
||||||
|
if (!found) return { error: NextResponse.json({ error: SETUP_ERROR }, { status: 503 }) } as const;
|
||||||
|
return { baseUrl: found.baseUrl } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET/PUT/DELETE /api/ai/opencode/providers — lets a user add "any LLM
|
||||||
|
* OpenCode supports" from inside this app, rather than only whatever was
|
||||||
|
* already authenticated via its own CLI. See lib/ai/opencode.ts's module
|
||||||
|
* note on why this only covers API-key providers for now, not OAuth ones.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const result = await requireOpencode(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
try {
|
||||||
|
const providers = await listOpencodeProviders(result.baseUrl);
|
||||||
|
return NextResponse.json({ providers }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
} catch (cause) {
|
||||||
|
logger.error('opencode providers list failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||||
|
return NextResponse.json({ error: 'Could not list OpenCode providers' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const result = await requireOpencode(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
let body: { providerID?: unknown; key?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const providerID = typeof body.providerID === 'string' ? body.providerID.trim() : '';
|
||||||
|
const key = typeof body.key === 'string' ? body.key.trim() : '';
|
||||||
|
if (!providerID || !key) {
|
||||||
|
return NextResponse.json({ error: 'providerID and key are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setOpencodeProviderKey(result.baseUrl, providerID, key);
|
||||||
|
// VERIFY rather than trust the 200: OpenCode accepts a bare API key for
|
||||||
|
// every provider (confirmed live), but does not consider every provider
|
||||||
|
// "connected" from that alone - Snowflake Cortex, for one real example,
|
||||||
|
// needs SNOWFLAKE_ACCOUNT alongside its token, and a single key field
|
||||||
|
// silently leaves it unconnected with no error from the PUT itself. The
|
||||||
|
// provider's own `env` array length does NOT predict this reliably either
|
||||||
|
// (Azure needs two env vars and DOES connect from one key) - the only
|
||||||
|
// honest source of truth is asking OpenCode again.
|
||||||
|
const after = await listOpencodeProviders(result.baseUrl);
|
||||||
|
const nowConnected = after.find((p) => p.id === providerID)?.connected === true;
|
||||||
|
if (!nowConnected) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: false,
|
||||||
|
error: `OpenCode stored the key but does not show ${providerID} as connected — it likely needs more than one credential field (check its requirements with the opencode CLI: opencode auth login ${providerID}).`,
|
||||||
|
}, { status: 200 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (cause) {
|
||||||
|
logger.error('opencode provider auth failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) });
|
||||||
|
return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not add the provider' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const result = await requireOpencode(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const providerID = request.nextUrl.searchParams.get('providerID')?.trim();
|
||||||
|
if (!providerID) {
|
||||||
|
return NextResponse.json({ error: 'providerID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeOpencodeProvider(result.baseUrl, providerID);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (cause) {
|
||||||
|
logger.error('opencode provider removal failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) });
|
||||||
|
return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not remove the provider' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { DEFAULT_AI_ENTITLEMENT, type AiPolicy } from '@/lib/ai/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this)
|
||||||
|
*
|
||||||
|
* `enabled` mirrors the admin FeatureGates toggle. `entitlement.classes`
|
||||||
|
* reflects real configuration, not a hardcoded guess: `server` only appears
|
||||||
|
* when AI_SERVER_BASE_URL is actually set (app/api/ai/server/* would 503
|
||||||
|
* otherwise) - this is enforcement point 1 (docs §10), cosmetic-only, the
|
||||||
|
* client hiding what it can't use; the real gate is checkAndAssignSeat() on
|
||||||
|
* every /api/ai/server/chat call, not this list.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const policy = configManager.getPolicy();
|
||||||
|
const consoleConfig = configManager.getAiConsoleConfig();
|
||||||
|
|
||||||
|
// A class must be BOTH infra-available AND not explicitly disabled by
|
||||||
|
// the admin console (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6) to reach
|
||||||
|
// users. Missing classesEnabled entries default to allowed, so this
|
||||||
|
// changes nothing until an admin actually touches the console.
|
||||||
|
const classAllowed = (cls: (typeof DEFAULT_AI_ENTITLEMENT.classes)[number]) => consoleConfig.classesEnabled[cls] !== false;
|
||||||
|
const classes: typeof DEFAULT_AI_ENTITLEMENT.classes = [];
|
||||||
|
if (classAllowed('local')) classes.push('local');
|
||||||
|
if (classAllowed('public')) classes.push('public');
|
||||||
|
if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server');
|
||||||
|
// `opencode` is offered whenever the admin hasn't disabled it — unlike
|
||||||
|
// `server` there is no env var to gate on, because availability is "is a
|
||||||
|
// local `opencode serve` listening right now", which changes minute to
|
||||||
|
// minute and is answered by /api/ai/opencode/models (503 when absent).
|
||||||
|
// Advertising the class and letting that probe report the truth beats
|
||||||
|
// hiding it based on a stale check at policy-fetch time.
|
||||||
|
if (classAllowed('opencode')) classes.push('opencode');
|
||||||
|
|
||||||
|
const aiPolicy: AiPolicy = {
|
||||||
|
enabled: policy.features.aiAssistantEnabled,
|
||||||
|
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
|
||||||
|
publicConsentVersion: consoleConfig.consent?.version ?? null,
|
||||||
|
retrievalEnabled: consoleConfig.retrievalEnabled,
|
||||||
|
consent: consoleConfig.consent,
|
||||||
|
publicProviderAllowlist: consoleConfig.publicProviderAllowlist,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(aiPolicy, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AI policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const MAX_QUERY_CHARS = 512;
|
||||||
|
const DEFAULT_LIMIT = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/retrieve — the server embedding leg (docs/AI-ASSISTANT-CONCEPT.md
|
||||||
|
* §7 step 2). Real JMAP fetch + real Ollama embeddings + real cosine ranking
|
||||||
|
* (lib/ai/retrieval/mail-embeddings.ts), not a mock.
|
||||||
|
*
|
||||||
|
* ACL note (§7 step 2b): this only ever embeds/searches the *authenticated
|
||||||
|
* session's own* JMAP account — there is no shared-mailbox fan-out to
|
||||||
|
* pre-filter yet, since group accounts are still deferred entirely (matches
|
||||||
|
* the doc's own "shared-mailbox retrieval ships server-only" decision, which
|
||||||
|
* itself hasn't been reached because there's no group account to retrieve
|
||||||
|
* from). Nothing here can leak across accounts because nothing crosses the
|
||||||
|
* account boundary in the first place.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await getStalwartCredentials(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.AI_SERVER_BASE_URL) {
|
||||||
|
return new NextResponse(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||||
|
// §6): an admin can disable mail-content-to-embeddings augmentation
|
||||||
|
// independent of disabling the `server` chat class outright.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
if (!configManager.getAiConsoleConfig().retrievalEnabled) {
|
||||||
|
return NextResponse.json({ error: 'retrieval is disabled by admin policy' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { query?: unknown; limit?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = typeof body.query === 'string' ? body.query.trim() : '';
|
||||||
|
if (!query) {
|
||||||
|
return NextResponse.json({ error: 'query is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (query.length > MAX_QUERY_CHARS) {
|
||||||
|
return NextResponse.json({ error: 'query too long' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const limit = typeof body.limit === 'number' ? Math.min(Math.max(Math.trunc(body.limit), 1), 20) : DEFAULT_LIMIT;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scored = await serverSearchMail(auth.serverUrl, auth.authHeader, query, limit);
|
||||||
|
const chunks = await hydrateMailRefs(auth.serverUrl, auth.authHeader, scored.map((s) => s.ref));
|
||||||
|
|
||||||
|
const contextBlock = chunks
|
||||||
|
.map((c, i) => `[${i + 1}] Subject: ${c.title}\n${c.text}`)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
hits: chunks.map((c, i) => ({ ref: c.ref, title: c.title, snippet: c.text.slice(0, 200), rank: i + 1 })),
|
||||||
|
contextBlock,
|
||||||
|
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
} catch (cause) {
|
||||||
|
logger.error('ai retrieve failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||||
|
return NextResponse.json({ error: 'retrieval unavailable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 200 * 1024;
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OllamaChatResponse {
|
||||||
|
message?: { content?: string };
|
||||||
|
prompt_eval_count?: number;
|
||||||
|
eval_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/server/chat — the one real enforcement chokepoint for the
|
||||||
|
* `server` AI class (docs/AI-ASSISTANT-CONCEPT.md §10 point 2: "re-validates
|
||||||
|
* ... entitlement against live state; rejects on mismatch ... never trusts
|
||||||
|
* the client"). Every call re-checks the seat; nothing here is cosmetic.
|
||||||
|
*
|
||||||
|
* Retrieval already happened client-side (the same /api/offline/search leg
|
||||||
|
* `local`/`public` use) — this route receives the already-built prompt
|
||||||
|
* messages and only proxies the model call + records the metering entry
|
||||||
|
* that IS the billing record (lib/ai/entitlement.ts).
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await getStalwartCredentials(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||||
|
// §6): the admin console can disable the whole `server` class even when
|
||||||
|
// AI_SERVER_BASE_URL stays configured (e.g. keeping infra up for staging
|
||||||
|
// while turning it off for users).
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
if (configManager.getAiConsoleConfig().classesEnabled.server === false) {
|
||||||
|
return NextResponse.json({ error: 'the server-hosted AI class is disabled by admin policy' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const seat = await checkAndAssignSeat(auth.username);
|
||||||
|
if (!seat.allowed) {
|
||||||
|
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await request.text();
|
||||||
|
if (rawBody.length > MAX_BODY_BYTES) {
|
||||||
|
return NextResponse.json({ error: 'request too large' }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { model?: unknown; messages?: unknown };
|
||||||
|
try {
|
||||||
|
body = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = typeof body.model === 'string' ? body.model : '';
|
||||||
|
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
|
||||||
|
if (!model || !messages || messages.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||||
|
if (allowlist && !allowlist.includes(model)) {
|
||||||
|
return NextResponse.json({ error: `model "${model}" is not on the admin allow-list` }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.AI_SERVER_BASE_URL;
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model, messages, stream: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: `AI server returned ${res.status}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as OllamaChatResponse;
|
||||||
|
const content = data.message?.content;
|
||||||
|
if (!content) {
|
||||||
|
return NextResponse.json({ error: 'AI server returned no message content' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordUsage({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
username: auth.username,
|
||||||
|
model,
|
||||||
|
promptTokens: data.prompt_eval_count ?? 0,
|
||||||
|
completionTokens: data.eval_count ?? 0,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ answer: content, seatJustAssigned: seat.seatJustAssigned === true });
|
||||||
|
} catch (cause) {
|
||||||
|
logger.error('ai server chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||||
|
return NextResponse.json({ error: 'AI server unreachable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/server/models — list models on the centrally-hosted `server`
|
||||||
|
* class runtime (docs/AI-ASSISTANT-CONCEPT.md §2.1: "the same self-hosted
|
||||||
|
* open-weight model stack as `local`... running on VNC's own infrastructure
|
||||||
|
* instead of the user's laptop"). Tonight, `AI_SERVER_BASE_URL` stands in for
|
||||||
|
* that infra with the Ollama already running on this developer's Mac — see
|
||||||
|
* the module comment in lib/ai/entitlement.ts. Swapping to the real
|
||||||
|
* EU/CH-hosted instance tomorrow is a config change, not a rewrite.
|
||||||
|
*
|
||||||
|
* Listing models is not a billable action (doc §10 point 1 — cosmetic), so
|
||||||
|
* this only requires a valid session, not a seat.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await getStalwartCredentials(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.AI_SERVER_BASE_URL;
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`);
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: `upstream returned ${res.status}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { models?: Array<{ name: string; capabilities?: string[] }> };
|
||||||
|
// Excludes embedding-only models (e.g. nomic-embed-text, used by
|
||||||
|
// lib/ai/retrieval/mail-embeddings.ts) from the *chat* picker — Ollama
|
||||||
|
// lists them in the same /api/tags response, but calling /api/chat with
|
||||||
|
// one fails outright. `capabilities` absent (older Ollama) fails open
|
||||||
|
// rather than hiding every model on an upgrade.
|
||||||
|
let chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
|
||||||
|
|
||||||
|
// Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6). null = every
|
||||||
|
// completion-capable model (today's behavior, unchanged).
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||||
|
if (allowlist) {
|
||||||
|
const allowed = new Set(allowlist);
|
||||||
|
chatModels = chatModels.filter((m) => allowed.has(m.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) });
|
||||||
|
} catch (cause) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: cause instanceof Error ? cause.message : 'AI server unreachable' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { encryptSession } from '@/lib/auth/crypto';
|
||||||
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
|
||||||
|
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
import {
|
||||||
|
ImpersonationJwtError,
|
||||||
|
impersonationReplayCache,
|
||||||
|
verifyImpersonationJwt,
|
||||||
|
} from '@/lib/impersonation/jwt';
|
||||||
|
import {
|
||||||
|
readImpersonationConfig,
|
||||||
|
resolveImpersonationServerUrl,
|
||||||
|
} from '@/lib/impersonation/master-config';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const IMPERSONATION_SLOT = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impersonation cookies deliberately omit Max-Age so the browser treats
|
||||||
|
* them as session cookies - the impersonated session ends when the user
|
||||||
|
* closes the browser, not 30 days later. Impersonation is a temporary
|
||||||
|
* support handoff; a normal password login is the only thing that should
|
||||||
|
* survive a browser restart.
|
||||||
|
*/
|
||||||
|
function impersonationCookieOptions() {
|
||||||
|
const { maxAge: _maxAge, ...rest } = getCookieOptions();
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auth/impersonate?token=<jwt>
|
||||||
|
*
|
||||||
|
* Master-user impersonation via signed JWT. The token carries the target
|
||||||
|
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||||
|
* master credentials from env, then mints the same session cookies the
|
||||||
|
* password-login path produces. The browser is redirected to "/?impersonated=1" (see
|
||||||
|
* ImpersonationReconciler, GH #646) and the
|
||||||
|
* SPA hydrates as if the user had just logged in with master@target%master.
|
||||||
|
*
|
||||||
|
* Returns 404 when the feature is not configured so an unconfigured
|
||||||
|
* deployment does not advertise the endpoint.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const config = readImpersonationConfig();
|
||||||
|
if (!config) {
|
||||||
|
// Not configured - behave exactly like an unknown route.
|
||||||
|
return new NextResponse('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = request.nextUrl.searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims;
|
||||||
|
try {
|
||||||
|
claims = verifyImpersonationJwt(token, config.jwtSecret, {
|
||||||
|
expectedIssuer: config.expectedIssuer,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ImpersonationJwtError) {
|
||||||
|
logger.warn('Impersonation JWT rejected', { code: err.code });
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status });
|
||||||
|
}
|
||||||
|
logger.error('Impersonation JWT error', {
|
||||||
|
error: err instanceof Error ? err.message : 'Unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
|
||||||
|
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
|
||||||
|
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = await resolveImpersonationServerUrl();
|
||||||
|
if (!serverUrl) {
|
||||||
|
logger.error('Impersonation requested but jmapServerUrl is not configured');
|
||||||
|
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedServerUrl: string;
|
||||||
|
try {
|
||||||
|
normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stalwart master-user impersonation: username = "<target>%<master>",
|
||||||
|
// password = <master_password>. Per Stalwart docs:
|
||||||
|
// https://stalw.art/docs/auth/authorization/administrator/
|
||||||
|
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
|
||||||
|
const authHeader = `Basic ${Buffer.from(
|
||||||
|
`${impersonatedUsername}:${config.masterPassword}`,
|
||||||
|
).toString('base64')}`;
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionToken = encryptSession(
|
||||||
|
normalizedServerUrl,
|
||||||
|
impersonatedUsername,
|
||||||
|
config.masterPassword,
|
||||||
|
);
|
||||||
|
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
|
||||||
|
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
|
||||||
|
serverUrl: normalizedServerUrl,
|
||||||
|
username: impersonatedUsername,
|
||||||
|
authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Structured audit log - operators rely on this for security review.
|
||||||
|
logger.info('Impersonation session granted', {
|
||||||
|
event: 'impersonation_granted',
|
||||||
|
jti: claims.jti,
|
||||||
|
mailbox: claims.mailbox,
|
||||||
|
tenant_id: claims.tenant_id,
|
||||||
|
actor_user_id: claims.actor_user_id,
|
||||||
|
iss: claims.iss,
|
||||||
|
ip:
|
||||||
|
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||||
|
request.headers.get('x-real-ip') ||
|
||||||
|
null,
|
||||||
|
referer: request.headers.get('referer'),
|
||||||
|
user_agent: request.headers.get('user-agent'),
|
||||||
|
});
|
||||||
|
|
||||||
|
void recordLogin(impersonatedUsername, normalizedServerUrl);
|
||||||
|
|
||||||
|
// Use a relative Location header so the browser resolves it against the
|
||||||
|
// public request URL. NextResponse.redirect(new URL('/', request.url))
|
||||||
|
// would absolutise to the container's internal bind (http://0.0.0.0:3000)
|
||||||
|
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 303,
|
||||||
|
headers: { Location: '/?impersonated=1' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getMetadata, getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same-origin OAuth metadata (discovery) proxy.
|
||||||
|
*
|
||||||
|
* The login page needs the authorization_endpoint to build the PKCE authorize
|
||||||
|
* URL in the browser. Discovering it directly from the browser means a
|
||||||
|
* cross-origin fetch to the IdP's /.well-known/* documents, which is subject
|
||||||
|
* to CORS: providers like Authentik serve those documents without an
|
||||||
|
* Access-Control-Allow-Origin header, so the browser blocks the response and
|
||||||
|
* discovery fails (issue #382). Performing discovery here - server to server,
|
||||||
|
* where CORS does not apply - and handing the result back as a same-origin
|
||||||
|
* response sidesteps the problem entirely.
|
||||||
|
*
|
||||||
|
* The discovery URL is resolved from admin config (via server_id), never from
|
||||||
|
* client input, so this cannot be abused as an open SSRF proxy. Endpoint URLs
|
||||||
|
* in the discovered document are still gated by the SSRF validator inside
|
||||||
|
* discoverOAuth. The returned fields are public well-known metadata.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const serverId = request.nextUrl.searchParams.get('server_id');
|
||||||
|
|
||||||
|
let discoveryUrl: string;
|
||||||
|
try {
|
||||||
|
({ discoveryUrl } = getRequiredConfig(serverId));
|
||||||
|
} catch {
|
||||||
|
// OAuth not configured for this server - surface as "no metadata" rather
|
||||||
|
// than a 500 so the login page just hides the SSO button.
|
||||||
|
return NextResponse.json({ error: 'OAuth not configured' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await getMetadata(serverId);
|
||||||
|
if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
|
||||||
|
logger.warn('OAuth metadata discovery returned no usable endpoints', { discoveryUrl });
|
||||||
|
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(metadata, {
|
||||||
|
// Mirror the in-process discovery cache TTL so repeated login-page loads
|
||||||
|
// hit the CDN/browser cache instead of re-running discovery.
|
||||||
|
headers: { 'Cache-Control': 'private, max-age=600' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('OAuth metadata discovery error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
|
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { createPairing } from '@/lib/auth/pairing-store';
|
||||||
|
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
|
||||||
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
|
// Desktop side of the cross-device QR login. The caller must be a signed-in
|
||||||
|
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
|
||||||
|
// refresh that token to (a) prove the session is live and (b) obtain a fresh
|
||||||
|
// access token to hand the phone, then stash the bundle under a one-time
|
||||||
|
// pairing code. The desktop renders the returned code as a QR; the phone
|
||||||
|
// redeems it at /api/auth/pair/redeem.
|
||||||
|
//
|
||||||
|
// Token sharing note: the phone receives the SAME refresh token as the desktop.
|
||||||
|
// That is correct for OAuth servers (such as Stalwart in its default config)
|
||||||
|
// that do not rotate refresh tokens on use. If the server rotates refresh
|
||||||
|
// tokens, the two devices would fight over the latest token — such deployments
|
||||||
|
// should disable rotation for this client or use a token-exchange grant.
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
try {
|
||||||
|
// Step-up gate: minting a pairing code grants new-device access, so it
|
||||||
|
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
|
||||||
|
// The client turns this 401 into a re-auth redirect, then retries.
|
||||||
|
if (!(await hasValidPairReauth())) {
|
||||||
|
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const slot =
|
||||||
|
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
|
||||||
|
? body.slot
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
|
if (!refreshToken) {
|
||||||
|
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||||
|
|
||||||
|
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||||
|
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
|
||||||
|
|
||||||
|
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.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
|
// Stale session — clear the dead cookie so the user is prompted to log
|
||||||
|
// back in, mirroring the token route's behaviour.
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
|
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json();
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
logger.error('Pair create: refresh response missing access_token');
|
||||||
|
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the server rotated the refresh token, persist the new one back to the
|
||||||
|
// desktop's cookie so this very session keeps working. The phone will get
|
||||||
|
// the same (new) token below.
|
||||||
|
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId, serverUrl } = getRequiredConfig(serverId);
|
||||||
|
|
||||||
|
const { code, expiresIn } = createPairing({
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: effectiveRefreshToken,
|
||||||
|
expiresIn: tokens.expires_in,
|
||||||
|
tokenEndpoint,
|
||||||
|
clientId,
|
||||||
|
serverUrl,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { consumePairing } from '@/lib/auth/pairing-store';
|
||||||
|
|
||||||
|
// Phone side of the cross-device QR login. The app POSTs the pairing code it
|
||||||
|
// scanned; we hand back the OAuth token bundle the desktop stashed at
|
||||||
|
// /api/auth/pair/create. The code is the only credential required — it is
|
||||||
|
// high-entropy, single-use, and expires within ~2 minutes — so this route is
|
||||||
|
// intentionally unauthenticated (the scanning device has no webmail cookies).
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
|
||||||
|
if (!pairingCode || typeof pairingCode !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = consumePairing(pairingCode);
|
||||||
|
if (!tokens) {
|
||||||
|
// Unknown, expired, or already redeemed — do not distinguish.
|
||||||
|
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
flow: 'oauth',
|
||||||
|
server_url: tokens.serverUrl,
|
||||||
|
access_token: tokens.accessToken,
|
||||||
|
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
|
||||||
|
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
|
||||||
|
token_endpoint: tokens.tokenEndpoint,
|
||||||
|
client_id: tokens.clientId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user