Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80afc7834 | ||
|
|
d8975d21be | ||
|
|
b0918686a2 | ||
|
|
71c9e1b3ee | ||
|
|
10759d777b | ||
|
|
c956ab5a4e | ||
|
|
f9b790668d | ||
|
|
0e5fef6f31 | ||
|
|
832265e091 | ||
|
|
8940a10397 | ||
|
|
1e1d9586d7 | ||
|
|
305864f0c7 | ||
|
|
d475214224 | ||
|
|
73204d4e0a | ||
|
|
533187d328 | ||
|
|
70db04a93f | ||
|
|
e8eae6a1fc | ||
|
|
8ea5a6a097 | ||
|
|
e6a2314a1e | ||
|
|
7836ce27e7 | ||
|
|
a5fc4a4893 | ||
|
|
358d893c33 | ||
|
|
ae211b5adf | ||
|
|
d0b2b5201e | ||
|
|
0130858584 | ||
|
|
6b923eb4c2 | ||
|
|
d033dad20f | ||
|
|
f882a14ee0 | ||
|
|
990d06b8e1 | ||
|
|
b6564c81f1 | ||
|
|
8df46d8512 | ||
|
|
5072861687 | ||
|
|
919bd9e29b | ||
|
|
c5e42607af | ||
|
|
e64e2c6d53 | ||
|
|
36274f35f4 | ||
|
|
d5e717e9ce | ||
|
|
13f78f6207 | ||
|
|
e7aedd9f6b | ||
|
|
fc76d0e576 | ||
|
|
a1b7194261 | ||
|
|
66d15f6ab0 | ||
|
|
561e62a05c | ||
|
|
b4b81684e0 | ||
|
|
898f625f1f | ||
|
|
105199991f | ||
|
|
89be5c1f6e | ||
|
|
ae671bc70a | ||
|
|
78e34b5522 | ||
|
|
42d79b3dcf | ||
|
|
2d4178a281 | ||
|
|
ac0e11e548 | ||
|
|
0543cc42de | ||
|
|
06c803388b | ||
|
|
58b00162d9 | ||
|
|
08084d96e8 | ||
|
|
8b63e84cba | ||
|
|
a2e76f2cf0 | ||
|
|
72f22d1235 | ||
|
|
76945ee5b5 | ||
|
|
d8b7d6e5f4 | ||
|
|
1369316401 | ||
|
|
29b0cc1f86 | ||
|
|
74f422b523 | ||
|
|
0fefdeec4c | ||
|
|
b99e7979db | ||
|
|
64b43a42dd | ||
|
|
9d35870863 | ||
|
|
526580cbb6 | ||
|
|
d3366ca7cd | ||
|
|
048a90f876 | ||
|
|
abc24cf381 | ||
|
|
9d21f19ab8 | ||
|
|
07a0072b87 | ||
|
|
dbe5705b47 | ||
|
|
0ba3255382 | ||
|
|
2e34b2d004 | ||
|
|
fc92b342ce | ||
|
|
45c4afd22b | ||
|
|
d9f1ecb834 | ||
|
|
a37190e0fc | ||
|
|
cd37c6c980 | ||
|
|
316567f219 | ||
|
|
e5c61b32e3 | ||
|
|
a41d5ff2c0 | ||
|
|
769a139e70 | ||
|
|
7729d9f33e | ||
|
|
8e13f2bce9 | ||
|
|
4e28ffef9c | ||
|
|
d06a7a2c76 | ||
|
|
886f3bb840 | ||
|
|
b501d7af33 | ||
|
|
c3ea57986f | ||
|
|
048947ea7e | ||
|
|
7834a660a5 | ||
|
|
eee7c49439 | ||
|
|
dbab502be4 | ||
|
|
f4d5279a09 | ||
|
|
c8430a8c14 | ||
|
|
63efa5d0f0 | ||
|
|
8cda80752f | ||
|
|
abeb2d2477 | ||
|
|
a7c74e8d8b | ||
|
|
4b7ed35300 | ||
|
|
6463a1ef87 | ||
|
|
f85dc3fa76 | ||
|
|
fb05b1a847 | ||
|
|
5b751217d5 | ||
|
|
07e6f663db | ||
|
|
23ed41bddf | ||
|
|
3e33c766d6 | ||
|
|
83078736f5 | ||
|
|
7ccb50e4f5 | ||
|
|
1032c9d9cf | ||
|
|
ecbecc9d86 | ||
|
|
797426335a | ||
|
|
e7f2068105 | ||
|
|
afc51165f4 | ||
|
|
87a6354e42 | ||
|
|
edc8c17e35 | ||
|
|
2a700f5690 | ||
|
|
a6ce798862 | ||
|
|
30fd008310 | ||
|
|
3608d4b657 | ||
|
|
7c1ee7f161 | ||
|
|
d978d626ae | ||
|
|
1059164255 | ||
|
|
741668a831 | ||
|
|
5f06ebfaec | ||
|
|
b5e79b37ed | ||
|
|
7076ce5df3 | ||
|
|
3e00b2ccc2 | ||
|
|
5681592572 | ||
|
|
87dcfdbff6 | ||
|
|
b035bed3d8 | ||
|
|
94d032c847 | ||
|
|
347d30c3e0 | ||
|
|
720ef251ae | ||
|
|
24577ad2a9 | ||
|
|
06cdd459db | ||
|
|
fad217db8a | ||
|
|
4aeb3aa3d2 | ||
|
|
8baa9124a6 | ||
|
|
1b1b04d083 | ||
|
|
982f0b1cf5 | ||
|
|
b5f3cbc38e | ||
|
|
28464685a7 | ||
|
|
3a6aa7dccd | ||
|
|
2cc30a85f2 | ||
|
|
c5ac0cf8ce | ||
|
|
95ad27e0df | ||
|
|
b0e2674b11 | ||
|
|
46e67512ac | ||
|
|
994ecf8ec0 | ||
|
|
d486eb2a8f | ||
|
|
8f574799e2 | ||
|
|
7496d994e1 | ||
|
|
ec728dabf6 | ||
|
|
2474ff15d6 | ||
|
|
53c205b4ce | ||
|
|
7b88f4c21a | ||
|
|
20368533a0 | ||
|
|
ac7712f141 | ||
|
|
0b9d33cac4 | ||
|
|
d4aca1c009 | ||
|
|
a5ca6b7a79 | ||
|
|
964caabaaf | ||
|
|
9455646d66 | ||
|
|
c2f67001d6 | ||
|
|
b57362c5e8 | ||
|
|
d5d04728fb | ||
|
|
b99e1ccc05 | ||
|
|
3390324c2a | ||
|
|
5748475e8d | ||
|
|
accb51cc67 | ||
|
|
f81fe11dae | ||
|
|
5309e4863d | ||
|
|
f26a2ac9ad | ||
|
|
c37047d8ee | ||
|
|
d5ca31a38b | ||
|
|
0958182306 | ||
|
|
9f979054de | ||
|
|
86e671ea49 | ||
|
|
47794f799b | ||
|
|
b6200c14fc | ||
|
|
53b0224cce | ||
|
|
f02e02b401 | ||
|
|
52ce4f6128 | ||
|
|
a12e075780 | ||
|
|
597eb6b2bb | ||
|
|
ba953b57e5 | ||
|
|
3adf69bd4c | ||
|
|
cb1c7da78c | ||
|
|
64bc774b1e | ||
|
|
172f3bb31b | ||
|
|
8183a645a1 | ||
|
|
f69e543376 | ||
|
|
fbce6e5e9b | ||
|
|
5efb90640c | ||
|
|
93f90873e8 | ||
|
|
8b8a0c8458 | ||
|
|
fc37c08687 | ||
|
|
933bd97393 | ||
|
|
ed5304cd30 | ||
|
|
e6a3ce6772 | ||
|
|
acc49aeccb | ||
|
|
f19938b6bb | ||
|
|
997931e462 | ||
|
|
e837c0948a | ||
|
|
361099b2cc | ||
|
|
5105cf6489 | ||
|
|
01043096f3 | ||
|
|
af86ea8785 | ||
|
|
b32a14fbd5 | ||
|
|
14de1ac072 | ||
|
|
ddc3335854 | ||
|
|
c01743b53a | ||
|
|
508d7a80ef | ||
|
|
49ee49d2b8 | ||
|
|
89999ab10f | ||
|
|
39effe9a0b | ||
|
|
ad3a38e900 | ||
|
|
b4742b84ab | ||
|
|
9ddad24e2a | ||
|
|
9f3c216576 | ||
|
|
664c285cbb | ||
|
|
7fb2c3de7b | ||
|
|
24f4bfd5ad | ||
|
|
45595d95ab | ||
|
|
568ea35ed7 | ||
|
|
6243550c84 | ||
|
|
6650618682 | ||
|
|
b26d9fa163 | ||
|
|
0c6acbfaa2 | ||
|
|
af7323a0ab | ||
|
|
1950aff865 | ||
|
|
9013ffc5ad | ||
|
|
6d56d69ae3 | ||
|
|
80f4b37337 | ||
|
|
0e287c9117 | ||
|
|
a3ed18294f | ||
|
|
8e4404ab49 | ||
|
|
8b6c459453 | ||
|
|
8d6d409cc2 | ||
|
|
9962f2bd9b | ||
|
|
6c816e573d | ||
|
|
7f1dd16dfc | ||
|
|
09a5737e21 | ||
|
|
7d94e575b7 | ||
|
|
bfd554e65c | ||
|
|
a692f270da | ||
|
|
6999faced9 | ||
|
|
ea70df6805 | ||
|
|
736fa07dca | ||
|
|
83a683435a | ||
|
|
7efba64c39 | ||
|
|
79521baad8 | ||
|
|
c1f0da1217 | ||
|
|
8da4a2ad04 | ||
|
|
6f8a502cf2 | ||
|
|
98fc5d6ffc | ||
|
|
8a8469ac2a | ||
|
|
059fc979dc | ||
|
|
21f2e2e6ef | ||
|
|
82e5ce1d76 | ||
|
|
b92292fe8f | ||
|
|
6b7cb473aa | ||
|
|
ac2bacf42c | ||
|
|
21c62c6b82 | ||
|
|
1a998f9ece | ||
|
|
fd10c4c9ad | ||
|
|
8d73d2f575 | ||
|
|
22b9930168 | ||
|
|
7337854bb7 | ||
|
|
f6303840c5 | ||
|
|
31bcf947cb | ||
|
|
0f8ff1f21c | ||
|
|
61554fa53a | ||
|
|
da4b40115d | ||
|
|
c7f9c95596 | ||
|
|
5c31adb314 | ||
|
|
cea6aee038 | ||
|
|
cb32b11586 | ||
|
|
d0de79cba2 | ||
|
|
c52f2481d3 | ||
|
|
690f49e60c | ||
|
|
fa78267068 | ||
|
|
e0391f7dd4 | ||
|
|
0f6883bdf3 | ||
|
|
b5a406f8eb | ||
|
|
80ffbcd562 | ||
|
|
8715133868 | ||
|
|
75cecab73f | ||
|
|
f778707236 | ||
|
|
b6b9d5b787 | ||
|
|
d19506d32f | ||
|
|
8349167a32 | ||
|
|
cf2bae0bfb | ||
|
|
eb75d60df1 | ||
|
|
57f1f9da5b | ||
|
|
c8c7230331 | ||
|
|
7e193ef603 | ||
|
|
5b1cd3dc2f | ||
|
|
b895a66e24 | ||
|
|
25dd4be3f8 | ||
|
|
6d70826b40 | ||
|
|
2dbe335b30 | ||
|
|
9e94321c90 | ||
|
|
6e07efdfa8 | ||
|
|
5c6aa15564 | ||
|
|
62c01725f9 | ||
|
|
b404ac6068 | ||
|
|
4520a432c4 | ||
|
|
aecdac5aca | ||
|
|
f2d6695fdc | ||
|
|
5435753573 | ||
|
|
90cfc3f696 | ||
|
|
7b7e376ba2 | ||
|
|
f347dc7d4d | ||
|
|
d174a4832e | ||
|
|
bc1cd58491 | ||
|
|
86ade7f1f6 | ||
|
|
f39f66192f | ||
|
|
330e0e3570 | ||
|
|
a54f23e2ec | ||
|
|
f7db51b457 | ||
|
|
ebaa31c1e0 | ||
|
|
123417d931 | ||
|
|
d7b0441376 | ||
|
|
a4c74a5980 | ||
|
|
5e09f6b599 | ||
|
|
f2968f0235 | ||
|
|
fa8036aa3c | ||
|
|
7556b3ed8e | ||
|
|
786c9295a9 | ||
|
|
c1cc1e3e3d | ||
|
|
158f31ad4f | ||
|
|
5e4319473e | ||
|
|
82c78ebd1e | ||
|
|
628a49d84e | ||
|
|
3c8db21d2f | ||
|
|
550f4a19cc | ||
|
|
cd2c69f5f0 | ||
|
|
cadd015d20 | ||
|
|
5aa2219541 | ||
|
|
4e77df9ff5 | ||
|
|
b40e6caa4d | ||
|
|
2a3aa981d2 | ||
|
|
653c61081a | ||
|
|
cc3b5cb8c6 | ||
|
|
df153dcc56 | ||
|
|
5878c80fa6 | ||
|
|
d04beea6f8 | ||
|
|
d160d985c9 | ||
|
|
f0a25358b4 | ||
|
|
36e1753f89 | ||
|
|
9defe9681b | ||
|
|
2bb6999656 | ||
|
|
309b488861 | ||
|
|
67192cf5c4 | ||
|
|
c84473ae43 | ||
|
|
f6328937c9 | ||
|
|
80db2517fd | ||
|
|
918bd0b6b5 | ||
|
|
e22f422615 | ||
|
|
1826f51664 | ||
|
|
e547be9ab8 | ||
|
|
89140f1fee | ||
|
|
1b57d09d0d | ||
|
|
e7d67e3ddb | ||
|
|
a4b5ac6c4e | ||
|
|
9b22f9e157 | ||
|
|
2ab0f59f13 | ||
|
|
611368abc6 | ||
|
|
d3b3be2a52 | ||
|
|
11a2c95ea7 | ||
|
|
3e612c39b7 | ||
|
|
58de6bb737 | ||
|
|
f6d8befb56 | ||
|
|
b49aa050e9 | ||
|
|
2ef8465e8d | ||
|
|
7878596608 | ||
|
|
1f2f9f571e | ||
|
|
855cb86088 | ||
|
|
ed3cf96c6f | ||
|
|
fdb477f351 | ||
|
|
ac6cc1041a | ||
|
|
7d09a649ac | ||
|
|
040ed6e605 | ||
|
|
d7e36f5157 | ||
|
|
1a08003834 | ||
|
|
64bc6ea71c | ||
|
|
ef4161889a | ||
|
|
dd9d6083c0 | ||
|
|
027b6a45a1 | ||
|
|
b717e5d7ad | ||
|
|
74965f5f95 | ||
|
|
fa812faf6d | ||
|
|
b459151798 | ||
|
|
73679919fb | ||
|
|
58b0ae29e1 | ||
|
|
773931a538 | ||
|
|
1ef814fe6b | ||
|
|
b010c687f3 | ||
|
|
05950fb287 | ||
|
|
56a685983b | ||
|
|
339c0a2bb7 | ||
|
|
a790453c9a | ||
|
|
04eb8a8fbb | ||
|
|
4ab1bc9638 | ||
|
|
699b9a9c71 | ||
|
|
3a203d274d | ||
|
|
887511fdda | ||
|
|
64f58455ff | ||
|
|
5163ec0da2 | ||
|
|
c47eb013b4 | ||
|
|
d4cbe12316 | ||
|
|
794222c85e | ||
|
|
675c661114 | ||
|
|
7700efde1f | ||
|
|
16832124ce | ||
|
|
bf5ca3d3eb | ||
|
|
189b6cae84 | ||
|
|
9bf0114148 | ||
|
|
9f3bbb5ed2 | ||
|
|
85b981e1c7 | ||
|
|
eb5ac8b7dd | ||
|
|
5ef32af248 | ||
|
|
9cb5837e3e | ||
|
|
5ae9c2b7ac | ||
|
|
7d16fed34e | ||
|
|
db2701f1d7 | ||
|
|
76818e2c98 | ||
|
|
c4e084ffbb | ||
|
|
a10b28bf6a | ||
|
|
90c0110615 | ||
|
|
73c594bb36 | ||
|
|
903544c680 | ||
|
|
32e1ec9d22 | ||
|
|
3516a21329 | ||
|
|
f6198dee30 | ||
|
|
13f14df880 | ||
|
|
f3ae64cf21 | ||
|
|
1820804b9f | ||
|
|
d3d76b12f7 | ||
|
|
51ad6ed99d | ||
|
|
1c8cea9ada | ||
|
|
a0c190137d | ||
|
|
83e1845290 | ||
|
|
052d8c1584 | ||
|
|
7b0e2213cf | ||
|
|
05e6d15478 | ||
|
|
be0283381b | ||
|
|
379a92846e | ||
|
|
bb6163a70f | ||
|
|
10f009f5b1 | ||
|
|
19c882ed2f | ||
|
|
e7cb6ac6f0 | ||
|
|
38e488ee4a | ||
|
|
589fa30fd1 | ||
|
|
64d351d835 | ||
|
|
4151ecb7e7 | ||
|
|
f4b5e5f07d | ||
|
|
c1639ea589 | ||
|
|
21b0ab064c | ||
|
|
1c0d8e9964 | ||
|
|
8a7949a8ab | ||
|
|
3c36db33e9 | ||
|
|
59d0cfc457 | ||
|
|
72f6d96dbc | ||
|
|
49c81f5426 | ||
|
|
08ed1705b1 | ||
|
|
47088c30c0 | ||
|
|
539dbdc034 | ||
|
|
129af0bad9 | ||
|
|
a9d95dd6b7 | ||
|
|
6d0978d0ed | ||
|
|
13cdf08e22 | ||
|
|
e59bc9cd86 | ||
|
|
6478d27d1c | ||
|
|
76d1800ec7 | ||
|
|
03ba747bc0 | ||
|
|
9eb3889d51 | ||
|
|
5d4bc7a886 | ||
|
|
135af977af | ||
|
|
6dfe81cbcb | ||
|
|
3ec2821058 | ||
|
|
1c5ac8efba | ||
|
|
ce7fa0e314 | ||
|
|
0f464b21a3 | ||
|
|
7169da8dae | ||
|
|
0f56e2dfc3 | ||
|
|
1b086c80a7 | ||
|
|
c27d5d0412 | ||
|
|
7b3043bf46 | ||
|
|
da19160e87 | ||
|
|
790a801f30 | ||
|
|
b08c66a483 | ||
|
|
524aa9124d | ||
|
|
df85b5ac45 | ||
|
|
c009ddc915 | ||
|
|
5407e4533c | ||
|
|
98088c7734 | ||
|
|
0d6fb33043 | ||
|
|
1dbad1b454 | ||
|
|
2c8a6c8ec6 | ||
|
|
06b9946dd8 | ||
|
|
dbcdf15244 | ||
|
|
7e2067d0b1 | ||
|
|
7489f720ea | ||
|
|
140e844238 | ||
|
|
de55d5afbe | ||
|
|
c5fccb52cc | ||
|
|
507ca57a50 | ||
|
|
b2e072e02c | ||
|
|
357f63d01f | ||
|
|
e6422d842a | ||
|
|
99519af83a | ||
|
|
514ce6ad24 | ||
|
|
f57ce52198 | ||
|
|
2a9c921ba6 | ||
|
|
0dd924f148 | ||
|
|
678eb0896d | ||
|
|
233c1c8e81 | ||
|
|
f6a851a3d8 | ||
|
|
4fba9d7802 | ||
|
|
d41ecb054c | ||
|
|
ec0c1bc9af | ||
|
|
05b77d24fa | ||
|
|
4535303807 | ||
|
|
f232fdc30b | ||
|
|
dab72d80dc | ||
|
|
6f83464b24 | ||
|
|
41cd0a8925 | ||
|
|
fc02a15753 | ||
|
|
bd9c5a058a | ||
|
|
c1692cc6b9 | ||
|
|
5259c09a5c | ||
|
|
10f801d31b | ||
|
|
28d19db328 | ||
|
|
0e13bfc50e | ||
|
|
4f4aa22ca1 | ||
|
|
6bec01198d | ||
|
|
5dbceb43b4 | ||
|
|
bc92978fa9 | ||
|
|
893f83b79e | ||
|
|
bde23f6c4d | ||
|
|
e6d4b38c64 | ||
|
|
23264044e6 | ||
|
|
d51a381a79 | ||
|
|
da4459304f | ||
|
|
085699f202 | ||
|
|
2c43aa463a | ||
|
|
41db7b4ad1 | ||
|
|
d0a9fa9a71 | ||
|
|
ed0675aaff | ||
|
|
cad8f58468 | ||
|
|
819d58cf31 | ||
|
|
b951515e3e | ||
|
|
4c3265bca5 | ||
|
|
11672a13f7 | ||
|
|
9a8141a0eb | ||
|
|
5462fb0647 | ||
|
|
6b74b8f770 | ||
|
|
99fff1a1a1 | ||
|
|
645f233b83 | ||
|
|
643e2738bd | ||
|
|
cd356a29ce | ||
|
|
341be982f9 | ||
|
|
2e7565f9eb | ||
|
|
1637a9c938 | ||
|
|
67c2be1858 | ||
|
|
b8d4c01e60 | ||
|
|
e259ab86f7 | ||
|
|
029b3523cb | ||
|
|
f4657ad817 | ||
|
|
5efddff154 | ||
|
|
25efe93507 | ||
|
|
a393e67ef8 | ||
|
|
7f6758cbe9 | ||
|
|
c5644a3321 | ||
|
|
4fec03101f | ||
|
|
10fed0a85c | ||
|
|
fff6316192 | ||
|
|
61623f50b1 | ||
|
|
9f626407cf | ||
|
|
3a51678495 | ||
|
|
c730a0e733 | ||
|
|
80a9971508 | ||
|
|
85788e1d7d | ||
|
|
23680547ea | ||
|
|
9767ead9f3 | ||
|
|
910c9a7f1f | ||
|
|
0bc6c6abb6 | ||
|
|
6361e16dba | ||
|
|
74966a0bc7 |
No files matched your search
+39
-10
@@ -20,7 +20,10 @@
|
||||
"strict": 0,
|
||||
"no-undef": 0,
|
||||
"accessor-pairs": 2,
|
||||
"comma-dangle": [2, "always-multiline"],
|
||||
"comma-dangle": [
|
||||
2,
|
||||
"always-multiline"
|
||||
],
|
||||
"consistent-return": 2,
|
||||
"dot-notation": 2,
|
||||
"eqeqeq": 2,
|
||||
@@ -28,15 +31,28 @@
|
||||
"no-cond-assign": 2,
|
||||
"no-constant-condition": 2,
|
||||
"no-eval": 2,
|
||||
"no-inner-declarations": [0],
|
||||
"no-inner-declarations": [
|
||||
0
|
||||
],
|
||||
"no-unneeded-ternary": 2,
|
||||
"radix": 2,
|
||||
"semi": [2, "always"],
|
||||
"camelcase": [2, { "properties": "never" }],
|
||||
"semi": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"camelcase": [
|
||||
2,
|
||||
{
|
||||
"properties": "never"
|
||||
}
|
||||
],
|
||||
"comma-spacing": 2,
|
||||
"comma-style": 2,
|
||||
"eol-last": 2,
|
||||
"linebreak-style": [2, "unix"],
|
||||
"linebreak-style": [
|
||||
2,
|
||||
"unix"
|
||||
],
|
||||
"meteor/audit-argument-checks": 0,
|
||||
"new-parens": 2,
|
||||
"no-lonely-if": 2,
|
||||
@@ -45,7 +61,13 @@
|
||||
"no-spaced-func": 2,
|
||||
"no-trailing-spaces": 2,
|
||||
"operator-linebreak": 2,
|
||||
"quotes": [2, "single", { "avoidEscape": true }],
|
||||
"quotes": [
|
||||
2,
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"semi-spacing": 2,
|
||||
"space-unary-ops": 2,
|
||||
"arrow-spacing": 2,
|
||||
@@ -57,6 +79,7 @@
|
||||
"prefer-spread": 2,
|
||||
"prefer-template": 2,
|
||||
"no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
@@ -72,11 +95,18 @@
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"meteor": {
|
||||
"extensions": [".js", ".jsx"]
|
||||
"extensions": [
|
||||
".js",
|
||||
".jsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": ["prettier", "meteor"],
|
||||
"plugins": [
|
||||
"prettier",
|
||||
"meteor",
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"globals": {
|
||||
"Meteor": false,
|
||||
"Session": false,
|
||||
@@ -95,7 +125,6 @@
|
||||
"_": false,
|
||||
"Avatar": true,
|
||||
"Avatars": true,
|
||||
|
||||
"CollectionHooks": false,
|
||||
"ESSearchResults": false,
|
||||
"FastRender": false,
|
||||
@@ -110,7 +139,7 @@
|
||||
"Ps": true,
|
||||
"Restivus": false,
|
||||
"SimpleSchema": false,
|
||||
"T9n": false,
|
||||
"T9n": false,
|
||||
"TAPi18n": false,
|
||||
"AccountsTemplates": true,
|
||||
"Activities": true,
|
||||
|
||||
@@ -11,4 +11,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v6
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@v5
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -48,14 +48,14 @@ jobs:
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
name: Playwright E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: playwright-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Meteor unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Meteor
|
||||
run: curl https://install.meteor.com/ | sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Meteor unit tests
|
||||
run: meteor test --once --driver-package meteortesting:mocha
|
||||
env:
|
||||
TEST_WATCH: '0'
|
||||
|
||||
e2e-tests:
|
||||
name: Playwright E2E (${{ matrix.project }})
|
||||
runs-on: ubuntu-24.04
|
||||
needs: unit-tests
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
project: [chromium, firefox]
|
||||
# webkit excluded from CI (requires macOS runner) — run locally with npm run test:e2e:all
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
ports: ['27017:27017']
|
||||
options: >-
|
||||
--health-cmd "mongosh --eval 'db.adminCommand(\"ping\")'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Meteor
|
||||
run: curl https://install.meteor.com/ | sh
|
||||
|
||||
- name: Install app npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright test dependencies
|
||||
run: npm ci
|
||||
working-directory: tests/playwright
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps ${{ matrix.project }}
|
||||
working-directory: tests/playwright
|
||||
|
||||
- name: Start WeKan in background
|
||||
run: |
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_test \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
meteor run --settings settings.json &
|
||||
# Wait up to 120 s for WeKan to become available
|
||||
for i in $(seq 1 24); do
|
||||
curl -sf http://localhost:3000 > /dev/null && break
|
||||
echo "Waiting for WeKan... attempt $i"
|
||||
sleep 5
|
||||
done
|
||||
env:
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.project }})
|
||||
run: |
|
||||
npx playwright test \
|
||||
--project=${{ matrix.project }} \
|
||||
--reporter=github
|
||||
working-directory: tests/playwright
|
||||
env:
|
||||
WEKAN_BASE_URL: http://localhost:3000
|
||||
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_test
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report-${{ matrix.project }}
|
||||
path: tests/playwright/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload test results (screenshots / videos)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-${{ matrix.project }}
|
||||
path: tests/playwright/test-results/
|
||||
retention-days: 7
|
||||
|
||||
e2e-regression:
|
||||
name: Puppeteer regression suite
|
||||
runs-on: ubuntu-24.04
|
||||
needs: unit-tests
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
ports: ['27017:27017']
|
||||
options: >-
|
||||
--health-cmd "mongosh --eval 'db.adminCommand(\"ping\")'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Meteor
|
||||
run: curl https://install.meteor.com/ | sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Chromium
|
||||
run: sudo apt-get install -y chromium-browser
|
||||
|
||||
- name: Start WeKan in background
|
||||
run: |
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_regression \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
meteor run &
|
||||
for i in $(seq 1 24); do
|
||||
curl -sf http://localhost:3000 > /dev/null && break
|
||||
sleep 5
|
||||
done
|
||||
env:
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
- name: Run list-regressions e2e
|
||||
run: node tests/e2e/list-regressions.js
|
||||
env:
|
||||
WEKAN_BASE_URL: http://localhost:3000
|
||||
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_regression
|
||||
CHROMIUM_PATH: /usr/bin/chromium-browser
|
||||
HEADLESS: 'true'
|
||||
+132
-195
@@ -1,24 +1,38 @@
|
||||
name: Release All Platforms
|
||||
|
||||
# Triggered when maintainer pushes a version tag, e.g.: git tag v8.43 && git push --tags
|
||||
# Does not work:
|
||||
# Required GitHub Secrets:
|
||||
# QUAY_USERNAME - Quay.io username
|
||||
# QUAY_PASSWORD - Quay.io password or token
|
||||
# DOCKERHUB_USERNAME - Docker Hub username
|
||||
# DOCKERHUB_TOKEN - Docker Hub access token
|
||||
# SNAP_STORE_LOGIN - Snapcraft store credentials: run 'snapcraft export-login --snaps wekan --channels stable -' and base64-encode the output
|
||||
# Triggered manually via GitHub CLI or Actions UI.
|
||||
# Usage:
|
||||
# gh workflow run release-all.yml -f old_version=8.42 -f new_version=8.43
|
||||
#
|
||||
# Or use the convenience script (commits pending changes, pushes, and triggers):
|
||||
# ./releases/release-all.sh 8.42 8.43
|
||||
#
|
||||
# Required GitHub Secrets:
|
||||
# GHCR_AUTH - base64(username:token) for ghcr.io
|
||||
# DOCKERHUB_AUTH - base64(username:token) for Docker Hub
|
||||
# QUAY_AUTH - base64(username:token) for Quay.io
|
||||
# SNAP_AUTH - Snapcraft store credentials
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
old_version:
|
||||
description: 'Previous version number (e.g. 8.42, without v prefix)'
|
||||
required: true
|
||||
type: string
|
||||
new_version:
|
||||
description: 'New version number (e.g. 8.43, without v prefix)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
|
||||
# ─── 1. Extract version and release notes ───────────────────────────────────
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -27,10 +41,15 @@ jobs:
|
||||
changelog: ${{ steps.changelog.outputs.content }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
- name: Extract version from tag
|
||||
- name: Set version from input
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
VERSION="${{ needs.prepare.outputs.version }}"
|
||||
VERSION="${VERSION#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract release notes from CHANGELOG.md
|
||||
id: changelog
|
||||
@@ -65,15 +84,18 @@ jobs:
|
||||
# Therefore the full Meteor build always runs here on x86_64.
|
||||
# All other Linux architectures then rebuild only the native npm modules
|
||||
# from this bundle (much faster than a full Meteor build).
|
||||
# Node.js version must match what Meteor 3.5-beta.7 bundles: 24.14.1 (ABI 143).
|
||||
# Node.js version must match what Meteor 3.5-beta.7 bundles (detected automatically).
|
||||
build-amd64:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/v${{ inputs.new_version }}
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
@@ -93,11 +115,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip
|
||||
|
||||
# Pin to Node 24.14.1 — the exact version bundled by Meteor 3.5-beta.7.
|
||||
# Pin to detected latest Node.js 24.x — must match Meteor's bundled version.
|
||||
# Using the same version for build and npm rebuild ensures consistent native module ABI.
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.14.1'
|
||||
node-version: '${{ needs.prepare.outputs.version }}'
|
||||
|
||||
- name: Install Meteor
|
||||
run: |
|
||||
@@ -134,22 +156,26 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION_ARM64: ''
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential g++ make python3 curl unzip zip
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.14.1'
|
||||
|
||||
- name: Set Node.js version for arm64
|
||||
id: node_arm64
|
||||
run: |
|
||||
echo "NODE_VERSION_ARM64=24" >> $GITHUB_ENV
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Rebuild native modules for arm64
|
||||
- name: Rebuild native modules for arm64 in container
|
||||
run: |
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
|
||||
@@ -166,10 +192,12 @@ jobs:
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
cd bundle/programs/server
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
cd ../..
|
||||
# Use the latest available Node.js 24.x slim image for arm64
|
||||
docker run --rm --platform linux/arm64 \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:24-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
|
||||
- name: Clean up and create arm64 zip
|
||||
run: |
|
||||
@@ -184,14 +212,15 @@ jobs:
|
||||
# ─── 4. Build s390x bundle (rebuild native modules via QEMU container) ───────
|
||||
# Meteor 3.5-beta.7 does NOT support s390x (installer exits with error on that arch).
|
||||
# GitHub Actions has no native s390x runners.
|
||||
# Solution: QEMU emulation + node:24.14.1-slim (s390x variant) for npm rebuild.
|
||||
# node:24.14.1-slim is pinned to match Meteor 3.5-beta.7's bundled Node.js version exactly.
|
||||
# Solution: QEMU emulation + node:<version>-slim (s390x variant) for npm rebuild.
|
||||
build-s390x:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION_S390X: ''
|
||||
steps:
|
||||
- name: Set up QEMU for s390x
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -206,6 +235,11 @@ jobs:
|
||||
with:
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Set Node.js version for s390x
|
||||
id: node_s390x
|
||||
run: |
|
||||
echo "NODE_VERSION_S390X=24" >> $GITHUB_ENV
|
||||
|
||||
- name: Rebuild native modules for s390x in container
|
||||
run: |
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
@@ -224,11 +258,11 @@ jobs:
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
# node:24.14.1-slim s390x — same Node.js version as Meteor 3.5-beta.7 bundled
|
||||
# Use the latest available Node.js 24.x slim image for s390x
|
||||
docker run --rm --platform linux/s390x \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:24.14.1-slim \
|
||||
node:24-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
|
||||
- name: Clean up and create s390x zip
|
||||
@@ -244,13 +278,15 @@ jobs:
|
||||
# ─── 4b. Build ppc64le bundle (rebuild native modules via QEMU container) ────
|
||||
# Meteor 3.5-beta.7 does NOT support ppc64le (installer exits with error on that arch).
|
||||
# GitHub Actions has no native ppc64le runners.
|
||||
# Solution: QEMU emulation + node:24.14.1-slim (ppc64le variant) for npm rebuild.
|
||||
# Solution: QEMU emulation + node:<version>-slim (ppc64le variant) for npm rebuild.
|
||||
build-ppc64le:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NODE_VERSION_PPC64LE: ''
|
||||
steps:
|
||||
- name: Set up QEMU for ppc64le
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -265,6 +301,11 @@ jobs:
|
||||
with:
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Set Node.js version for ppc64le
|
||||
id: node_ppc64le
|
||||
run: |
|
||||
echo "NODE_VERSION_PPC64LE=24" >> $GITHUB_ENV
|
||||
|
||||
- name: Rebuild native modules for ppc64le in container
|
||||
run: |
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
@@ -283,11 +324,11 @@ jobs:
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
# node:24.14.1-slim ppc64le — same Node.js version as Meteor 3.5-beta.7 bundled
|
||||
# Use the latest available Node.js 24.x slim image for ppc64le
|
||||
docker run --rm --platform linux/ppc64le \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:24.14.1-slim \
|
||||
node:24-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
|
||||
- name: Clean up and create ppc64le zip
|
||||
@@ -300,65 +341,62 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-ppc64le.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 4c. Build armhf bundle (rebuild native modules via QEMU container) ──────
|
||||
# Meteor 3.5-beta.7 does NOT support arm/v7 (armhf). Installer explicitly rejects it.
|
||||
# GitHub Actions has no native arm/v7 runners.
|
||||
# Solution: QEMU emulation + node:24.14.1-slim (arm/v7 variant) for npm rebuild.
|
||||
# arm/v7 under QEMU is slowest — timeout set to 120 min.
|
||||
build-armhf:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Set up QEMU for arm/v7
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm
|
||||
|
||||
- name: Install unzip/zip
|
||||
run: sudo apt-get update && sudo apt-get install -y unzip zip
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Rebuild native modules for armhf in container
|
||||
run: |
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
rm -rf bundle/programs/server/node_modules
|
||||
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
|
||||
# which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
|
||||
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
|
||||
if [ -d "$BCRYPT_DIR" ]; then
|
||||
rm -rf "$BCRYPT_DIR"
|
||||
TMP=$(mktemp -d)
|
||||
pushd "$TMP" > /dev/null
|
||||
npm pack bcrypt@6.0.0
|
||||
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
|
||||
cd bcrypt && npm install --ignore-scripts && cd ..
|
||||
cp -a bcrypt "$BCRYPT_DIR"
|
||||
popd > /dev/null
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
# node:24.14.1-slim arm/v7 — same Node.js version as Meteor 3.5-beta.7 bundled
|
||||
docker run --rm --platform linux/arm/v7 \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:24.14.1-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
|
||||
- name: Clean up and create armhf zip
|
||||
run: |
|
||||
zip -r wekan-${VERSION}-armhf.zip bundle
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bundle-armhf
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-armhf.zip
|
||||
retention-days: 1
|
||||
# ─── 4c. Build armhf bundle — COMMENTED OUT ─────────────────────────────────
|
||||
# No official Node.js binaries exist for armhf (arm/v7).
|
||||
#build-armhf:
|
||||
# needs: [prepare, build-amd64]
|
||||
# runs-on: ubuntu-latest
|
||||
# timeout-minutes: 120
|
||||
# env:
|
||||
# VERSION: ${{ needs.prepare.outputs.version }}
|
||||
# steps:
|
||||
# - name: Set up QEMU for arm/v7
|
||||
# uses: docker/setup-qemu-action@v4
|
||||
# with:
|
||||
# platforms: arm
|
||||
#
|
||||
# - name: Install unzip/zip
|
||||
# run: sudo apt-get update && sudo apt-get install -y unzip zip
|
||||
#
|
||||
# - name: Download amd64 bundle
|
||||
# uses: actions/download-artifact@v8
|
||||
# with:
|
||||
# name: bundle-amd64
|
||||
#
|
||||
# - name: Rebuild native modules for armhf in container
|
||||
# run: |
|
||||
# unzip wekan-${VERSION}-amd64.zip
|
||||
# rm -rf bundle/programs/server/node_modules
|
||||
# # Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
|
||||
# # which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
|
||||
# BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
|
||||
# if [ -d "$BCRYPT_DIR" ]; then
|
||||
# rm -rf "$BCRYPT_DIR"
|
||||
# TMP=$(mktemp -d)
|
||||
# pushd "$TMP" > /dev/null
|
||||
# npm pack bcrypt@6.0.0
|
||||
# mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
|
||||
# cd bcrypt && npm install --ignore-scripts && cd ..
|
||||
# cp -a bcrypt "$BCRYPT_DIR"
|
||||
# popd > /dev/null
|
||||
# rm -rf "$TMP"
|
||||
# fi
|
||||
# # node:<version>-slim arm/v7 — same Node.js version as Meteor bundled
|
||||
# docker run --rm --platform linux/arm/v7 \
|
||||
# -v "$(pwd)/bundle:/bundle" \
|
||||
# -w /bundle/programs/server \
|
||||
# node:${NODE_VERSION}-slim \
|
||||
# sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
|
||||
#
|
||||
# - name: Clean up and create armhf zip
|
||||
# run: |
|
||||
# zip -r wekan-${VERSION}-armhf.zip bundle
|
||||
#
|
||||
# - uses: actions/upload-artifact@v7
|
||||
# with:
|
||||
# name: bundle-armhf
|
||||
# path: wekan-${{ needs.prepare.outputs.version }}-armhf.zip
|
||||
# retention-days: 1
|
||||
|
||||
# ─── 5. Build win64 bundle (rebuild native modules on Windows) ───────────────
|
||||
build-win64:
|
||||
@@ -370,7 +408,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.14.1'
|
||||
node-version: '${{ needs.prepare.outputs.version }}'
|
||||
|
||||
## With Node 24 these are already preinstalled at GitHub Windows runners
|
||||
#- name: Install node-gyp and build tools
|
||||
@@ -424,7 +462,7 @@ jobs:
|
||||
|
||||
# ─── 6. Create GitHub Release and upload all bundles ────────────────────────
|
||||
release:
|
||||
needs: [prepare, build-amd64, build-arm64, build-s390x, build-ppc64le, build-armhf, build-win64]
|
||||
needs: [prepare, build-amd64, build-arm64, build-s390x, build-ppc64le, build-win64]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
@@ -436,8 +474,9 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create GitHub Release with all bundles
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: v${{ needs.prepare.outputs.version }}
|
||||
name: v${{ needs.prepare.outputs.version }}
|
||||
body: ${{ needs.prepare.outputs.changelog }}
|
||||
files: |
|
||||
@@ -445,106 +484,4 @@ jobs:
|
||||
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-s390x.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-ppc64le.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-armhf.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-win64.zip
|
||||
|
||||
# ─── 7. Build and push multi-platform Docker image ──────────────────────────
|
||||
#docker:
|
||||
# needs: [prepare, release]
|
||||
# runs-on: ubuntu-latest
|
||||
# env:
|
||||
# VERSION: ${{ needs.prepare.outputs.version }}
|
||||
# steps:
|
||||
# - uses: actions/checkout@v6
|
||||
#
|
||||
# - name: Set up QEMU
|
||||
# uses: docker/setup-qemu-action@v4
|
||||
#
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v3
|
||||
#
|
||||
# - name: Log in to Docker Hub
|
||||
# uses: docker/login-action@v4
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
#
|
||||
# - name: Log in to Quay.io
|
||||
# uses: docker/login-action@v4
|
||||
# with:
|
||||
# registry: quay.io
|
||||
# username: ${{ secrets.QUAY_USERNAME }}
|
||||
# password: ${{ secrets.QUAY_PASSWORD }}
|
||||
#
|
||||
# - name: Log in to GitHub Container Registry
|
||||
# uses: docker/login-action@v4
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.actor }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
#
|
||||
# - name: Build and push Docker image to all registries
|
||||
# uses: docker/build-push-action@v7
|
||||
# with:
|
||||
# context: .
|
||||
# platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/ppc64le,linux/s390x
|
||||
# push: true
|
||||
# build-args: VERSION=${{ needs.prepare.outputs.version }}
|
||||
# tags: |
|
||||
# #wekanteam/wekan:v${{ needs.prepare.outputs.version }}
|
||||
# #wekanteam/wekan:latest
|
||||
# quay.io/wekan/wekan:v${{ needs.prepare.outputs.version }}
|
||||
# quay.io/wekan/wekan:latest
|
||||
# ghcr.io/wekan/wekan:v${{ needs.prepare.outputs.version }}
|
||||
# ghcr.io/wekan/wekan:latest
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
|
||||
# ─── 8. Build and publish snap packages (all architectures via Launchpad) ────
|
||||
# Uses snapcraft remote-build on Launchpad's native builders (more reliable
|
||||
# than QEMU for snap builds). Each architecture bundles its own database:
|
||||
# amd64, arm64 — MongoDB 7.x prebuilt native binary
|
||||
# armhf, s390x, ppc64el — MongoDB 7.x amd64 binary run via qemu-x86_64-static
|
||||
# (no prebuilt MongoDB binaries exist for these arches)
|
||||
#snap:
|
||||
# needs: [prepare, release]
|
||||
# runs-on: ubuntu-latest
|
||||
# env:
|
||||
# VERSION: ${{ needs.prepare.outputs.version }}
|
||||
# steps:
|
||||
# - uses: actions/checkout@v6
|
||||
#
|
||||
# - name: Install snapcraft
|
||||
# run: sudo snap install snapcraft --classic
|
||||
#
|
||||
# - name: Remote-build snap for all architectures via Launchpad
|
||||
# env:
|
||||
# SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_STORE_LOGIN }}
|
||||
# run: |
|
||||
# snapcraft remote-build \
|
||||
# --build-for=amd64,arm64,armhf,s390x,ppc64el \
|
||||
# --launchpad-accept-public-upload
|
||||
#
|
||||
# - name: Upload all snap files as artifacts
|
||||
# uses: actions/upload-artifact@v7
|
||||
# with:
|
||||
# name: snaps-all-arches
|
||||
# path: "*.snap"
|
||||
# retention-days: 30
|
||||
#
|
||||
# # Publish to edge — NOT stable.
|
||||
# # Stable channel holds the old WeKan version until database migrations are ready.
|
||||
# # Promote to stable manually via the Snap Store dashboard when migrations exist.
|
||||
# - name: Publish all snaps to Snap Store (candidate, beta, edge)
|
||||
# env:
|
||||
# SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_STORE_LOGIN }}
|
||||
# run: |
|
||||
# for snap_file in *.snap; do
|
||||
# echo "Publishing $snap_file ..."
|
||||
# snapcraft upload --release=edge "$snap_file"
|
||||
# done
|
||||
#
|
||||
# - name: Upload all snaps to GitHub Release
|
||||
# uses: softprops/action-gh-release@v2
|
||||
# with:
|
||||
# files: "*.snap"
|
||||
@@ -1,168 +0,0 @@
|
||||
name: Test suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
jobs:
|
||||
# the following are optional jobs and need to be configured according
|
||||
# to this project's settings:
|
||||
#
|
||||
# lintcode:
|
||||
# name: Javascript lint
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: checkout
|
||||
# uses: actions/checkout@v6
|
||||
#
|
||||
# - name: setup node
|
||||
# uses: actions/setup-node@v1
|
||||
# with:
|
||||
# node-version: '12.x'
|
||||
#
|
||||
# - name: cache dependencies
|
||||
# uses: actions/cache@v1
|
||||
# with:
|
||||
# path: ~/.npm
|
||||
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-node-
|
||||
#
|
||||
# - run: npm install
|
||||
# - run: npm run lint:code
|
||||
#
|
||||
# lintstyle:
|
||||
# name: SCSS lint
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [lintcode]
|
||||
# steps:
|
||||
# - name: checkout
|
||||
# uses: actions/checkout@v6
|
||||
#
|
||||
# - name: setup node
|
||||
# uses: actions/setup-node@v1
|
||||
# with:
|
||||
# node-version: '12.x'
|
||||
#
|
||||
# - name: cache dependencies
|
||||
# uses: actions/cache@v1
|
||||
# with:
|
||||
# path: ~/.npm
|
||||
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-node-
|
||||
# - run: npm install
|
||||
# - run: npm run lint:style
|
||||
#
|
||||
# lintdocs:
|
||||
# name: documentation lint
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [lintcode,lintstyle]
|
||||
# steps:
|
||||
# - name: checkout
|
||||
# uses: actions/checkout@v6
|
||||
#
|
||||
# - name: setup node
|
||||
# uses: actions/setup-node@v1
|
||||
# with:
|
||||
# node-version: '12.x'
|
||||
#
|
||||
# - name: cache dependencies
|
||||
# uses: actions/cache@v1
|
||||
# with:
|
||||
# path: ~/.npm
|
||||
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-node-
|
||||
#
|
||||
# - run: npm install
|
||||
# - run: npm run lint:markdown
|
||||
|
||||
tests:
|
||||
name: Meteor ${{ matrix.meteor }} tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# CHECKOUTS
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# CACHING
|
||||
- name: Install Meteor
|
||||
id: cache-meteor-install
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: v1-meteor-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: |
|
||||
v1-meteor-
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
id: cache-meteor-npm
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: v1-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
v1-npm-
|
||||
|
||||
- name: Cache Meteor build
|
||||
id: cache-meteor-build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
.meteor/local/resolver-result-cache.json
|
||||
.meteor/local/plugin-cache
|
||||
.meteor/local/isopacks
|
||||
.meteor/local/bundler-cache/scanner
|
||||
key: v1-meteor_build_cache-${{ github.ref }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
v1-meteor_build_cache-
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.14.1'
|
||||
|
||||
- name: Setup meteor
|
||||
uses: meteorengineer/setup-meteor@v3
|
||||
with:
|
||||
meteor-release: '3.5-beta.7'
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
run: meteor npm install
|
||||
|
||||
- name: Run Tests
|
||||
run: sh ./test-wekan.sh -cv
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-folder
|
||||
path: .coverage/
|
||||
|
||||
coverage:
|
||||
name: Coverage report
|
||||
runs-on: ubuntu-latest
|
||||
needs: [tests]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download coverage
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: coverage-folder
|
||||
path: .coverage/
|
||||
|
||||
|
||||
- name: Coverage Report
|
||||
uses: VeryGoodOpenSource/very_good_coverage@v3.0.0
|
||||
with:
|
||||
path: ".coverage/lcov.info"
|
||||
min_coverage: 1 # TODO add tests and increase to 95!
|
||||
+11
@@ -1,4 +1,5 @@
|
||||
*~
|
||||
/*.log
|
||||
*.sw*
|
||||
.meteor-spk
|
||||
*.sublime-workspace
|
||||
@@ -53,3 +54,13 @@ _build
|
||||
*/build-assets
|
||||
*/build-chunks
|
||||
.rsdoctor
|
||||
|
||||
# Tests
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Claude AI
|
||||
.claude/
|
||||
|
||||
# Tools like mongosh etc so that they work inside Flatpak sandbox
|
||||
.tools/
|
||||
+13
-17
@@ -6,9 +6,9 @@
|
||||
meteor-base@1.5.2
|
||||
|
||||
# Build system
|
||||
ecmascript@0.17.0
|
||||
ecmascript@0.18.0
|
||||
standard-minifier-js@3.2.0
|
||||
rspack@1.0.0
|
||||
rspack@1.1.0
|
||||
|
||||
blaze@3.0.2
|
||||
|
||||
@@ -16,18 +16,11 @@ blaze@3.0.2
|
||||
aldeed:collection2
|
||||
aldeed:schema-index@4.0.0-beta.5
|
||||
reywood:publish-composite
|
||||
mongo@2.3.0-beta350.7
|
||||
|
||||
# The holy trifecta - this is a replacement for subs-manager
|
||||
# for IndexedDB persistence and auto-sync
|
||||
jam:offline
|
||||
# for optimistic methods with offline queuing
|
||||
jam:method
|
||||
# global subscription caching
|
||||
jam:pub-sub
|
||||
mongo@2.4.0-beta350.12
|
||||
|
||||
# Account system
|
||||
accounts-password@3.2.3-beta350.7
|
||||
accounts-password@3.3.0-beta350.12
|
||||
accounts-express
|
||||
communitypackages:core
|
||||
communitypackages:unstyled
|
||||
communitypackages:flow-routing
|
||||
@@ -44,7 +37,7 @@ wekan-accounts-oidc
|
||||
check@1.5.0
|
||||
random@1.2.2
|
||||
reactive-dict@1.3.2
|
||||
session@1.2.2
|
||||
session@1.2.3-beta350.12
|
||||
tracker@1.3.4
|
||||
audit-argument-checks@1.0.8
|
||||
|
||||
@@ -52,18 +45,21 @@ audit-argument-checks@1.0.8
|
||||
ostrio:i18n
|
||||
reactive-var@1.0.13
|
||||
shell-server@0.7.0
|
||||
email@3.2.0-beta350.7
|
||||
email@3.2.0-beta350.12
|
||||
dynamic-import@0.7.4
|
||||
wekan-markdown
|
||||
quave:synced-cron
|
||||
ostrio:cookies
|
||||
ostrio:files
|
||||
# lmieulet:meteor-coverage
|
||||
meteortesting:mocha
|
||||
meteortesting:mocha@3.0.0
|
||||
matb33:collection-hooks
|
||||
service-configuration@1.3.5
|
||||
minifier-css@2.0.1
|
||||
ejson@1.2.0-beta350.7
|
||||
minifier-css@2.0.2-beta350.12
|
||||
ejson@1.2.0-beta350.12
|
||||
logging@1.3.6
|
||||
wekan-fullcalendar
|
||||
wekan-fontawesome
|
||||
jam:offline
|
||||
jam:method
|
||||
jam:pub-sub
|
||||
+1
-1
@@ -1 +1 @@
|
||||
METEOR@3.5-beta.7
|
||||
METEOR@3.5-beta.12
|
||||
+48
-48
@@ -1,48 +1,48 @@
|
||||
accounts-base@3.3.0-beta350.7
|
||||
accounts-oauth@1.4.6
|
||||
accounts-password@3.2.3-beta350.7
|
||||
accounts-base@3.3.0-beta350.12
|
||||
accounts-oauth@1.4.7-beta350.12
|
||||
accounts-password@3.3.0-beta350.12
|
||||
accounts-express@1.0.0-beta350.12
|
||||
aldeed:collection2@4.1.5
|
||||
aldeed:schema-index@4.0.0
|
||||
aldeed:simple-schema@1.13.1
|
||||
allow-deny@2.1.0
|
||||
audit-argument-checks@1.0.8
|
||||
autoupdate@2.0.1
|
||||
babel-compiler@7.13.0
|
||||
babel-compiler@7.14.0
|
||||
babel-runtime@1.5.2
|
||||
base64@1.0.13
|
||||
binary-heap@1.0.12
|
||||
blaze@3.0.2
|
||||
blaze-tools@2.0.0
|
||||
base64@1.0.14-beta350.12
|
||||
binary-heap@1.0.13-beta350.12
|
||||
blaze@3.0.3
|
||||
blaze-tools@2.0.1
|
||||
boilerplate-generator@2.1.0
|
||||
caching-compiler@2.0.1
|
||||
caching-html-compiler@2.0.0
|
||||
callback-hook@1.6.1
|
||||
caching-html-compiler@2.0.1
|
||||
callback-hook@1.8.0-beta350.12
|
||||
check@1.5.0
|
||||
communitypackages:core@2.0.3
|
||||
communitypackages:flow-routing@2.1.0
|
||||
communitypackages:unstyled@2.0.1
|
||||
core-runtime@1.0.0
|
||||
ddp@1.4.2
|
||||
ddp-client@3.2.0-beta350.7
|
||||
ddp-common@1.4.4
|
||||
ddp-rate-limiter@1.3.0-beta350.7
|
||||
ddp-server@3.2.0-beta350.7
|
||||
ddp-client@3.3.0-beta350.12
|
||||
ddp-common@1.4.5-beta350.12
|
||||
ddp-rate-limiter@1.3.0-beta350.12
|
||||
ddp-server@3.3.0-beta350.12
|
||||
diff-sequence@1.1.3
|
||||
dynamic-import@0.7.4
|
||||
ecmascript@0.17.0
|
||||
ecmascript@0.18.0
|
||||
ecmascript-runtime@0.8.3
|
||||
ecmascript-runtime-client@0.12.3
|
||||
ecmascript-runtime-client@0.13.0
|
||||
ecmascript-runtime-server@0.11.1
|
||||
ejson@1.2.0-beta350.7
|
||||
email@3.2.0-beta350.7
|
||||
ejson@1.2.0-beta350.12
|
||||
email@3.2.0-beta350.12
|
||||
es5-shim@4.8.1
|
||||
facts-base@1.0.2
|
||||
fetch@0.1.6
|
||||
fetch@0.2.0-beta350.12
|
||||
geojson-utils@1.0.12
|
||||
hot-code-push@1.0.5
|
||||
html-tools@2.0.0
|
||||
htmljs@2.0.1
|
||||
http@1.4.4
|
||||
html-tools@2.0.1
|
||||
htmljs@2.0.2
|
||||
id-map@1.2.0
|
||||
inter-process-messaging@0.1.2
|
||||
jam:method@1.9.1
|
||||
@@ -53,36 +53,36 @@ lai:collection-extensions@1.0.0
|
||||
localstorage@1.2.1
|
||||
logging@1.3.6
|
||||
matb33:collection-hooks@2.1.0
|
||||
meteor@2.2.0
|
||||
meteor@2.3.1-beta350.12
|
||||
meteor-base@1.5.2
|
||||
meteortesting:browser-tests@1.4.2
|
||||
meteortesting:mocha@2.1.0
|
||||
meteortesting:mocha-core@8.0.1
|
||||
minifier-css@2.0.1
|
||||
minifier-js@3.1.0
|
||||
minimongo@2.1.0-beta350.7
|
||||
meteortesting:browser-tests@1.8.0
|
||||
meteortesting:mocha@3.3.0
|
||||
meteortesting:mocha-core@8.2.0
|
||||
minifier-css@2.0.2-beta350.12
|
||||
minifier-js@3.2.0
|
||||
minimongo@2.2.0-beta350.12
|
||||
modern-browsers@0.2.3
|
||||
modules@0.20.3
|
||||
modules-runtime@0.13.2
|
||||
mongo@2.3.0-beta350.7
|
||||
mongo@2.4.0-beta350.12
|
||||
mongo-decimal@0.2.0
|
||||
mongo-dev-server@1.1.1
|
||||
mongo-id@1.0.9
|
||||
npm-mongo@6.16.1
|
||||
oauth@3.0.2
|
||||
oauth@3.0.4-beta350.12
|
||||
oauth2@1.3.3
|
||||
observe-sequence@2.0.0
|
||||
observe-sequence@2.0.1
|
||||
ordered-dict@1.2.0
|
||||
ostrio:cookies@2.9.1
|
||||
ostrio:cookies@2.9.2
|
||||
ostrio:cstorage@4.0.1
|
||||
ostrio:files@3.0.1
|
||||
ostrio:flow-router-extra@3.12.1
|
||||
ostrio:flow-router-extra@3.15.3
|
||||
ostrio:i18n@3.2.1
|
||||
promise@1.0.0
|
||||
quave:synced-cron@2.4.0
|
||||
raix:eventemitter@1.0.0
|
||||
random@1.2.2
|
||||
rate-limit@1.2.0-beta350.7
|
||||
rate-limit@1.2.0-beta350.12
|
||||
react-fast-refresh@0.3.0
|
||||
reactive-dict@1.3.2
|
||||
reactive-var@1.0.13
|
||||
@@ -90,24 +90,24 @@ reload@1.3.2
|
||||
retry@1.1.1
|
||||
reywood:publish-composite@1.9.0
|
||||
routepolicy@1.1.2
|
||||
rspack@1.0.0
|
||||
rspack@1.1.0
|
||||
service-configuration@1.3.5
|
||||
session@1.2.2
|
||||
session@1.2.3-beta350.12
|
||||
sha@1.0.10
|
||||
shell-server@0.7.0
|
||||
socket-stream-client@0.7.0-beta350.7
|
||||
spacebars@2.0.0
|
||||
spacebars-compiler@2.0.0
|
||||
socket-stream-client@0.7.0-beta350.12
|
||||
spacebars@2.0.1
|
||||
spacebars-compiler@2.0.1
|
||||
standard-minifier-js@3.2.0
|
||||
templating@1.4.4
|
||||
templating-compiler@2.0.0
|
||||
templating-runtime@2.0.1
|
||||
templating-tools@2.0.0
|
||||
tools-core@1.0.0
|
||||
templating@1.4.5
|
||||
templating-compiler@2.0.1
|
||||
templating-runtime@2.0.2
|
||||
templating-tools@2.0.1
|
||||
tools-core@1.1.0
|
||||
tracker@1.3.4
|
||||
typescript@5.9.3
|
||||
url@1.3.5
|
||||
webapp@2.2.0-beta350.7
|
||||
typescript@5.10.0
|
||||
url@1.3.6-beta350.12
|
||||
webapp@2.2.0-beta350.12
|
||||
webapp-hashing@1.1.2
|
||||
wekan-accounts-cas@0.2.0
|
||||
wekan-accounts-lockout@1.1.0
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
npm-packages/
|
||||
tests/
|
||||
+1081
-10
File diff suppressed because it is too large.
Load diff
+10
-7
@@ -1,21 +1,21 @@
|
||||
FROM ubuntu:24.04
|
||||
FROM ubuntu:26.04
|
||||
LABEL maintainer="wekan"
|
||||
LABEL org.opencontainers.image.ref.name="ubuntu"
|
||||
LABEL org.opencontainers.image.version="24.04"
|
||||
LABEL org.opencontainers.image.version="26.04"
|
||||
LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
|
||||
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=8.80
|
||||
ARG VERSION=9.32
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
NODE_VERSION=v24.14.1 \
|
||||
METEOR_RELEASE=METEOR@3.5-beta.7 \
|
||||
NODE_VERSION=v24.16.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-beta.12 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=11.11.0 \
|
||||
SRC_PATH=./ \
|
||||
@@ -190,7 +190,7 @@ rm -f "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt.asc
|
||||
ln -s "/usr/local/bin/node" "/usr/local/bin/nodejs"
|
||||
|
||||
# NPM configuration
|
||||
npm install -g npm@${NPM_VERSION} --production
|
||||
npm install -g npm@${NPM_VERSION}
|
||||
chown --recursive wekan:wekan /home/wekan/
|
||||
|
||||
# Temporary Tar swap for Meteor bundle
|
||||
@@ -203,13 +203,16 @@ cd /home/wekan/app
|
||||
wget "https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
unzip "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
npm install --production --no-audit --prefix ./bundle/programs/server
|
||||
npm install --prefix ./bundle/programs/server
|
||||
mv /home/wekan/app/bundle /build
|
||||
|
||||
# Restore original tar
|
||||
mv $(which tar)~ $(which tar)
|
||||
|
||||
# Cleanup
|
||||
# Remove unused Go-based pebble binary shipped by base image to reduce CVE surface.
|
||||
apt-get remove --purge --assume-yes pebble || true
|
||||
rm -f /usr/bin/pebble
|
||||
apt-get remove --purge --assume-yes ${BUILD_DEPS}
|
||||
apt-get autoremove --assume-yes
|
||||
apt-get clean --assume-yes
|
||||
|
||||
@@ -47,12 +47,13 @@ See bottom of https://github.com/wekan/wekan/issues/3874
|
||||
## FAQ
|
||||
|
||||
**NOTE**:
|
||||
|
||||
- Please read the [FAQ](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md) first
|
||||
- Please don't feed the [trolls](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md#why-am-i-called-a-troll) and [spammers](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md#why-am-i-called-a-spammer) that are mentioned in the FAQ :)
|
||||
|
||||
## About WeKan ®
|
||||
|
||||
WeKan ® is an completely [Open Source][open_source] and [Free software][free_software]
|
||||
WeKan ® is a completely [Open Source][open_source] and [Free software][free_software]
|
||||
collaborative kanban board application with MIT license.
|
||||
|
||||
Whether you’re maintaining a personal todo list, planning your holidays with some friends,
|
||||
@@ -76,15 +77,15 @@ that by providing one-click installation on various platforms.
|
||||
- 1 GB RAM minimum free for WeKan ®. Production server should have minimum total 4 GB RAM.
|
||||
For thousands of users, for example with [Docker](https://github.com/wekan/wekan/blob/main/docker-compose.yml): 3 frontend servers,
|
||||
each having 2 CPU and 2 wekan-app containers. One backend wekan-db server with many CPUs.
|
||||
- Enough disk space and alerts about low disk space. If you run out disk space, MongoDB database gets corrupted.
|
||||
- Enough disk space and alerts about low disk space. If you run out of disk space, MongoDB database gets corrupted.
|
||||
- SECURITY: Updating to newest WeKan ® version very often. Please check you do not have automatic updates of Sandstorm or Snap turned off.
|
||||
Old versions have security issues because of old versions Node.js etc. Only newest WeKan ® is supported.
|
||||
WeKan ® on Sandstorm is not usually affected by any Standalone WeKan ® (Snap/Docker/Source) security issues.
|
||||
- [Reporting all new bugs immediately](https://github.com/wekan/wekan/issues).
|
||||
New features and fixes are added to WeKan ® [many times a day](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- [Backups](https://github.com/wekan/wekan/blob/main/docs/Backup/Backup.md) of WeKan ® database once a day miminum.
|
||||
- [Backups](https://github.com/wekan/wekan/blob/main/docs/Backup/Backup.md) of WeKan ® database once a day minimum.
|
||||
Bugs, updates, users deleting list or card, harddrive full, harddrive crash etc can eat your data. There is no undo yet.
|
||||
Some bug can cause WeKan ® board to not load at all, requiring manual fixing of database content.
|
||||
Some bugs can cause WeKan ® board to not load at all, requiring manual fixing of database content.
|
||||
|
||||
## Roadmap and Demo
|
||||
|
||||
@@ -92,7 +93,7 @@ that by providing one-click installation on various platforms.
|
||||
|
||||
[Developer Documentation][dev_docs]
|
||||
|
||||
- There is many companies and individuals contributing code to WeKan ®, to add features and bugfixes
|
||||
- There are many companies and individuals contributing code to WeKan ®, to add features and bugfixes
|
||||
[many times a day](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- [Please add Add new Feature Requests and Bug Reports immediately](https://github.com/wekan/wekan/issues).
|
||||
- [Commercial Support](https://wekan.fi/commercial-support/).
|
||||
@@ -102,12 +103,75 @@ By working directly with WeKan ® you get the benefit of active maintenance and
|
||||
|
||||
## Getting Started with Development
|
||||
|
||||
The default branch uses [Meteor 2 with Node.js 14](https://wekan.fi/install/).
|
||||
The default branch uses Meteor 3.5-beta.7 with Node.js 24.x.
|
||||
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
|
||||
|
||||
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-select-option-1-to-install-dependencies-and-then-enter). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
|
||||
|
||||
Please refer to the [developer documentation](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md) for more information.
|
||||
|
||||
## First-Time Setup for Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before building WeKan from source, ensure you have:
|
||||
|
||||
- **Git** - for cloning the repository
|
||||
- **Node.js 24.x** - WeKan requires Node.js 24.x
|
||||
- **Meteor** - the JavaScript framework WeKan is built with
|
||||
|
||||
### Installing Node.js 24.x
|
||||
|
||||
```bash
|
||||
# Using nvm (recommended)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
source ~/.zshrc # or ~/.bashrc
|
||||
nvm install 24
|
||||
nvm use 24
|
||||
```
|
||||
|
||||
### Installing Meteor
|
||||
|
||||
```bash
|
||||
curl https://install.meteor.com/ | sh
|
||||
```
|
||||
|
||||
### Building WeKan
|
||||
|
||||
The `rebuild-wekan.sh` script guides you through a three-stage process:
|
||||
|
||||
1. **Install dependencies (Option 1)** - Downloads all required Meteor packages and npm modules
|
||||
2. **Build WeKan (Option 2)** - Compiles the application
|
||||
3. **Run WeKan (Option 3)** - Starts the development server at http://localhost:4000
|
||||
|
||||
```bash
|
||||
# Clone your fork
|
||||
git clone git@github.com:YOUR_USERNAME/wekan.git
|
||||
cd wekan
|
||||
|
||||
# Make the script executable
|
||||
chmod +x rebuild-wekan.sh
|
||||
|
||||
# Step 1: Install dependencies
|
||||
./rebuild-wekan.sh
|
||||
# Press 1 and Enter
|
||||
|
||||
# Step 2: Build WeKan (after dependencies complete)
|
||||
./rebuild-wekan.sh
|
||||
# Press 2 and Enter
|
||||
|
||||
# Step 3: Run WeKan in development mode
|
||||
./rebuild-wekan.sh
|
||||
# Press 3 and Enter
|
||||
```
|
||||
|
||||
|
||||
### WSL Users
|
||||
|
||||
WSL users can use Snap Candidate. See [install docs](https://wekan.fi/install/).
|
||||
|
||||
The development server will start at http://localhost:4000. Any changes you make to the source code will automatically trigger a rebuild and refresh your browser.
|
||||
|
||||
## Screenshot
|
||||
|
||||
[More screenshots at Features page](https://github.com/wekan/wekan/tree/main/docs/Features)
|
||||
|
||||
+77
-77
@@ -1,38 +1,35 @@
|
||||
## About money
|
||||
## About Money: No bounty
|
||||
|
||||
See [CONTRIBUTING](CONTRIBUTING.md)
|
||||
There is no bounty, like described at [CONTRIBUTING.md](CONTRIBUTING.md),
|
||||
because WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
|
||||
## Responsible Security Disclosure
|
||||
## Steps for Coordinated Vulnerability Disclosure
|
||||
|
||||
1. To send email, if possible, use PGP key [security-at-wekan.fi.asc](security-at-wekan.fi.asc)
|
||||
2. Send info about security issue ONLY to security@wekan.fi . NOT TO ANYWHERE ELSE. NO CC, NO BCC.
|
||||
3. Wait for new WeKan release that fixes security issue to appear to top of
|
||||
https://github.com/wekan/wekan/blob/main/CHANGELOG.md
|
||||
4. We will thank you by adding you to Hall of Fame: https://wekan.fi/hall-of-fame/
|
||||
5. All vulnerability details will be private to security@wekan.fi ,
|
||||
unless you help all WeKan platforms to have a way to upgrade, like sending
|
||||
database migrations code to security@wekan.fi or PRs to https://github.com/wekan/wekan/pulls .
|
||||
There is no benefit to Wordwide Security Community to have more details about vulnerabilities,
|
||||
if Worldwide Security Community does not help to make upgrades possible.
|
||||
6. If there some day becomes available a way to upgrade all WeKan platforms,
|
||||
this page will be updated to add permission for security researchers
|
||||
to request new GHSA or CVE ID and publish your vulnerability details at your blog, talks, etc,
|
||||
and send that info also to security@wekan.fi to be added to
|
||||
Hall of Fame: https://wekan.fi/hall-of-fame/ to get Upgrade Bonus Point Stars.
|
||||
In that case, it will become possible for security@wekan.fi to publish all
|
||||
remaining private security details, and publicly thank Worldwide Security Community.
|
||||
### 1. Security Researcher:
|
||||
|
||||
## Bonus Points
|
||||
- **Report**: Please email details regarding security vulnerabilities to **security@wekan.fi**.
|
||||
- **Proof of Concept (PoC)**: If possible, include a fix or a reproduction script/code.
|
||||
- **Encryption**: An optional PGP key is available at [security-at-wekan.fi.asc](security-at-wekan.fi.asc).
|
||||
|
||||
- If you include code for fixing security issue
|
||||
### 2. WeKan Security Team:
|
||||
|
||||
## Losing Points
|
||||
- **Remediation**: Please wait for a new WeKan release that addresses the issue.
|
||||
Fixes are announced at the top of the [ChangeLog](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- **Recognition**: We will acknowledge your contribution by adding you to our
|
||||
[Hall of Fame](https://wekan.fi/hall-of-fame/).
|
||||
- **CVE Policy**: WeKan Security does not facilitate **CVE Coordination** due to
|
||||
our rapid release cycle (new versions are released almost daily).
|
||||
|
||||
- If you ask about [bounty](CONTRIBUTING.md). There is no bounty. WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
- If you forget to include vulnerability details.
|
||||
- If you send info about security issue to somewhere else than security@wekan.fi
|
||||
### 3. Post-Release and Public Disclosure:
|
||||
|
||||
## How should reports be formatted?
|
||||
- **Independent CVE Assignment**: After the fix is public, you are welcome to coordinate
|
||||
**CVE assignment** independently. Please send the assigned CVE IDs to **security@wekan.fi**,
|
||||
and we will update your entry in the [Hall of Fame](https://wekan.fi/hall-of-fame/).
|
||||
- Publicity: If you publish blog posts, give talks, or write articles about the
|
||||
reported vulnerabilities, please send the links to us. We will link them
|
||||
to your entry in the [Hall of Fame](https://wekan.fi/hall-of-fame/) for additional context.
|
||||
|
||||
## How Should Reports Be Formatted?
|
||||
|
||||
```
|
||||
Name: %name
|
||||
@@ -57,15 +54,19 @@ added to the WeKan Hall of Fame https://wekan.fi/hall-of-fame/
|
||||
No public domains, because all those are donated to Wekan Open Source project,
|
||||
and we don't have any permissions to do security scans on those donated servers.
|
||||
|
||||
Please don't perform research that could impact other users. Secondly, please keep
|
||||
the reports short and succinct. If we fail to understand the logics of your bug, we will tell you.
|
||||
Please don't perform research that could impact other users. Second, please keep
|
||||
the reports short and succinct. If we fail to understand the logic of your bug, we will tell you.
|
||||
|
||||
You can [Install Wekan](https://github.com/wekan/wekan/releases) to your own computer
|
||||
You can [Install Wekan](https://wekan.fi/install/) on your own computer
|
||||
and scan it's vulnerabilities there.
|
||||
|
||||
## About Wekan versions
|
||||
## About Wekan Versions
|
||||
|
||||
There are only 2 versions of Wekan: Standalone Wekan, and Sandstorm Wekan.
|
||||
There are 2 versions of Wekan: Standalone Wekan, and Sandstorm Wekan.
|
||||
|
||||
## Meteor Security
|
||||
|
||||
https://guide.meteor.com/security
|
||||
|
||||
### Standalone Wekan Security
|
||||
|
||||
@@ -74,11 +75,12 @@ like Snap and Docker have their own specific sandboxing etc features.
|
||||
|
||||
Standalone Wekan by default does not load any files from Internet, like fonts, CSS, etc.
|
||||
This also means all Standalone Wekan functionality works in offline local networks.
|
||||
WeKan is used at most countries of the world https://snapcraft.io/wekan
|
||||
and by by companies that have 30k users.
|
||||
WeKan is used in most countries of the world https://snapcraft.io/wekan
|
||||
and by companies that have 30k users.
|
||||
|
||||
- Wekan private board attachments are not accessible without logging in.
|
||||
- There is feature to set board public, so that board is visible without logging in in readonly mode, with realtime updates.
|
||||
- There is feature to set board public, so that board is visible without logging
|
||||
in readonly mode, with realtime updates.
|
||||
- Admin Panel has feature to disable all public boards, so all boards are private.
|
||||
|
||||
## SSL/TLS
|
||||
@@ -90,24 +92,25 @@ and by by companies that have 30k users.
|
||||
|
||||
## XSS
|
||||
|
||||
- Dompurify https://www.npmjs.com/package/dompurify
|
||||
- WeKan uses dompurify npm package to filter for XSS at fields like cards, as you can see from
|
||||
- DOMPurify https://www.npmjs.com/package/dompurify
|
||||
- WeKan uses DOMPurify npm package to filter for XSS at fields like cards, as you can see from
|
||||
[package.json](https://github.com/wekan/wekan/blob/main/package.json). Other used versions can be seen from
|
||||
[Meteor versions file](https://github.com/wekan/wekan/blob/main/.meteor/versions).
|
||||
- Forms can include markdown links, html, image tags etc like you see at https://wekan.github.io .
|
||||
- It's possible to add attachments to cards, and markdown/html links to files.
|
||||
- Dompurify cleans up viewed code, so Javascript in input fields does not execute
|
||||
- DOMPurify cleans up viewed code, so JavaScript in input fields does not execute
|
||||
- https://wekan.github.io/hall-of-fame/fieldbleed/
|
||||
- Reaction in comment is now checked, that it does not have extra added code
|
||||
- https://wekan.github.io/hall-of-fame/reactionbleed/
|
||||
- https://github.com/wekan/wekan/blob/main/packages/markdown/src/template-integration.js#L76
|
||||
|
||||
## QA about PubSub
|
||||
## QA About PubSub
|
||||
|
||||
Q:
|
||||
|
||||
Hello,
|
||||
I have just seen the Meteor DevTools Evolved extension and was wondering if anyone had asked themselves the question of security.
|
||||
I have just seen the Meteor DevTools Evolved extension and was wondering if anyone
|
||||
had asked themselves the question of security.
|
||||
Insofar as all data is shown in the minimongo tab in plain text.
|
||||
How can data be hidden from this extension?
|
||||
|
||||
@@ -115,32 +118,36 @@ A:
|
||||
|
||||
## PubSub
|
||||
|
||||
- It is not security issue to show some text or image, that user has permission to see. It is a security issue, if browserside is some text or image that user should not see.
|
||||
- Meteor has browserside minimongo database, made with Javascript, updated with Publish/Subscribe, PubSub.
|
||||
- Publish/Subscribe means, that realtime web framework reads database changes stream, and then immediately updates webpage,
|
||||
like like dashboards, chat, kanban. That is the point in any realtime web framework in any programming language.
|
||||
- Yes, you should check with Meteor DevTools Evolved Chromium/Firefox extension that at minimongo is only text that user has permission to see.
|
||||
- Do checking as logged in user, and logged out user.
|
||||
- It is not a security issue to show text or images that the user has permission to see.
|
||||
It is a security issue, if browserside is some text or image that user should not see.
|
||||
- Meteor has browserside minimongo database, made with JavaScript, updated with Publish/Subscribe, PubSub.
|
||||
- Publish/Subscribe means, that realtime web framework reads database changes stream,
|
||||
and then immediately updates webpage, like dashboards, chat, kanban.
|
||||
That is the point of any realtime web framework in any programming language.
|
||||
- Yes, you should check with Meteor DevTools Evolved Chromium/Firefox extension that
|
||||
at minimongo is only text that user has permission to see.
|
||||
- Check as a logged-in user and as a logged-out user.
|
||||
- Check permissions and sanitize before allowing some change, because someone could modify content of input field,
|
||||
PubSub/websocket data (for example with Burp Suite Community Edition), etc.
|
||||
- If you have REST API, also check that only those that have login token, and have permission, can view or edit text
|
||||
- If you have a REST API, also check that only those who have a login token and permission can view or edit text.
|
||||
- You should not include any data user is not allowed to see. Not to webpage text, not to websockets/PubSub, etc.
|
||||
- Minimongo should not have password hashes PubSub https://wekan.github.io/hall-of-fame/userbleed/
|
||||
- PubSub uses Websockets, so you need those to be enabled at webserver like Caddy/Nginx/Apache etc, examples of settings
|
||||
- PubSub uses WebSockets, so you need those to be enabled on the webserver like Caddy/Nginx/Apache etc, examples of settings
|
||||
at right menu of https://github.com/wekan/wekan/wiki
|
||||
- Clientside https://github.com/wekan/wekan/tree/main/client/components subscribes to
|
||||
PubSub https://github.com/wekan/wekan/tree/main/server/publications or calls meteor methods at https://github.com/wekan/wekan/tree/main/models
|
||||
- Clientside https://github.com/wekan/wekan/tree/main/client/components subscribes to
|
||||
PubSub https://github.com/wekan/wekan/tree/main/server/publications or calls meteor methods
|
||||
at https://github.com/wekan/wekan/tree/main/models
|
||||
- For Admin:
|
||||
- You can have input field for password https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- You can save password to database https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- Check that only current user or Admin can change password https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- Note that currentUser uses code like Meteor.user() in .js file
|
||||
- Do not have password hashes in PubSub https://github.com/wekan/wekan/blob/main/server/publications/users.js
|
||||
- Do not include password hashes in PubSub https://github.com/wekan/wekan/blob/main/server/publications/users.js
|
||||
- Only show Admin Panel to Admin https://github.com/wekan/wekan/blob/main/client/components/settings/settingBody.jade#L3
|
||||
- If there is a lot of data, use pagination https://github.com/wekan/wekan/blob/main/client/components/settings/peopleBody.js
|
||||
- Only have limited amount of data published in PubSub. Limit in MongoDB query in publications how much is published. Too much could make browser too slow.
|
||||
- Use Environment variables for any email etc passwords.
|
||||
- But what if you would like to remove minimongo? And only use Meteor methods for saving? In that case, you don't have realtime updates,
|
||||
- If there is a large amount of data, use pagination https://github.com/wekan/wekan/blob/main/client/components/settings/peopleBody.js
|
||||
- Only publish a limited amount of data in PubSub. Limit in MongoDB query in publications how much is published. Too much could make browser too slow.
|
||||
- Environment variables for email and other passwords.
|
||||
- But what if you would like to remove Minimongo and only use Meteor methods for saving? In that case, you don't have realtime updates,
|
||||
and you need to write much more code to load and save data yourself, handle any multi user data saving conflicts yourself,
|
||||
and many Meteor Atmospherejs.com PubSub using packages would not work anymore https://github.com/wekan/we
|
||||
|
||||
@@ -152,10 +159,10 @@ A:
|
||||
|
||||
## Permissions and Roles
|
||||
|
||||
- For any user permissions, it's best to use Meteor package package https://github.com/Meteor-Community-Packages/meteor-roles .
|
||||
- Currently WeKan has custom hardcoded permissions, WeKan does not yet use that meteor-roles package.
|
||||
- For any user permissions, it's best to use Meteor package https://github.com/Meteor-Community-Packages/meteor-roles .
|
||||
- Currently, WeKan has custom hardcoded permissions and does not yet use the meteor-roles package.
|
||||
- Using permissions at WeKan sidebar https://github.com/wekan/wekan/blob/main/client/components/sidebar/sidebar.js#L1854-L1875
|
||||
- List of roles https://github.com/wekan/wekan/wiki/REST-API-Role . Change at board or Admin Panel. Also Organizations/Teams.
|
||||
- List of roles: https://github.com/wekan/wekan/wiki/REST-API-Role . Change at board or Admin Panel. Also Organizations/Teams.
|
||||
- Worker role: https://github.com/wekan/wekan/issues/2788
|
||||
- Not implemented yet: Granular Roles https://github.com/wekan/wekan/issues/3022
|
||||
- Check is user logged in, with `if (Meteor.user()) {`
|
||||
@@ -164,8 +171,8 @@ A:
|
||||
|
||||
## Environment variables
|
||||
|
||||
- For any passwords, use environment variables, those are serverside
|
||||
- Do not copy environment variable to public variable that is visible browserside https://github.com/wekan/wekan/blob/main/server/max-size.js
|
||||
- For any passwords, use environment variables; those are serverside
|
||||
- Do not copy environment variables to public variables that are visible on the browserside https://github.com/wekan/wekan/blob/main/server/max-size.js
|
||||
|
||||
```
|
||||
Meteor.startup(() => {
|
||||
@@ -176,13 +183,13 @@ Meteor.startup(() => {
|
||||
```
|
||||
|
||||
- For serverside, you can set Meteor.settings.variablename, without text public
|
||||
- For WeKan kanban, there is feature for setting board public, it can be viewed by anyone, there is realtime updates. But
|
||||
- For WeKan kanban, there is a feature for setting a board to public; it can be viewed by anyone, with realtime updates. But
|
||||
- Some of those permissions are checked at users.js models at https://github.com/wekan/wekan/tree/main/models
|
||||
- Environment variables are used for email server passwords, etc, at all platforms https://github.com/wekan/wekan/commit/a781c0e7dcfdbe34c1483ee83cec12455b7026f7
|
||||
|
||||
## Escape HTML comment tags so that HTML comments are visible
|
||||
|
||||
- Someone reported, that it is problem that content of HTML comments in edit mode, are not visible at at view mode, so this makes HTML comments visible.
|
||||
- Someone reported that it was a problem that the content of HTML comments in edit mode was not visible in view mode, so this change makes HTML comments visible.
|
||||
- https://github.com/wekan/wekan/commit/167863d95711249e69bb3511175d73b34acbbdb3
|
||||
- https://wekan.github.io/hall-of-fame/invisiblebleed/
|
||||
|
||||
@@ -249,18 +256,16 @@ Meteor.startup(() => {
|
||||
|
||||
### Sandstorm Wekan Security
|
||||
|
||||
On Sandstorm platform using environment variable Standalone Wekan features like Admin Panel etc are
|
||||
turned off, because Sandstorm platform provides SSO for all apps running on Sandstorm.
|
||||
On the Sandstorm platform, Standalone WeKan features like the Admin Panel are turned off using environment variables, because Sandstorm platform provides SSO for all apps running on Sandstorm.
|
||||
|
||||
[Sandstorm](https://sandstorm.io) is separate Open Source platform that has been
|
||||
[Sandstorm](https://sandstorm.io) is a separate Open Source platform that has been
|
||||
[security audited](https://sandstorm.io/news/2017-03-02-security-review) and found bugs fixed.
|
||||
Sandstorm also has passwordless login, LDAP, SAML, Google etc auth options already.
|
||||
At Sandstorm code is read-only and signed by app maintainers, only grain content can be modified.
|
||||
Wekan at Sandstorm runs in sandboxed grain, it does not have access elsewhere without user-visible
|
||||
PowerBox request or opening randomly-generated API key URL.
|
||||
Sandstorm also has passwordless login, LDAP, SAML, Google, and other auth options already.
|
||||
On Sandstorm, code is read-only and signed by app maintainers; only grain content can be modified.
|
||||
WeKan on Sandstorm runs in a sandboxed grain; it does not have access elsewhere without a user-visible PowerBox request or opening a randomly-generated API key URL.
|
||||
Also read [Sandstorm Security Practices](https://docs.sandstorm.io/en/latest/using/security-practices/) and
|
||||
[Sandstorm Security non-events](https://docs.sandstorm.io/en/latest/using/security-non-events/).
|
||||
For Sandstorm specific security issues you can contact [kentonv](https://github.com/kentonv) by email.
|
||||
For Sandstorm specific security issues you can contact [kentonv](https://github.com/kentonv) by email.
|
||||
|
||||
## What Wekan bugs are eligible?
|
||||
|
||||
@@ -276,16 +281,11 @@ a security issue, we'd like to know about it, and also how to fix it:
|
||||
|
||||
## What Wekan bugs are NOT eligible?
|
||||
|
||||
Typical already known or "no impact" bugs such as:
|
||||
Typical already-known or 'no impact' bugs such as:
|
||||
|
||||
- [Wekan API old tokens not replaced correctly](https://github.com/wekan/wekan/issues/1437)
|
||||
- Missing Cookie flags on non-session cookies or 3rd party cookies
|
||||
- Logout CSRF
|
||||
- Social engineering
|
||||
- Denial of service
|
||||
- SSL BEAST/CRIME/etc. Wekan does not have SSL built-in, it uses Caddy/Nginx/Apache etc at front.
|
||||
Integrated Caddy support is updated often.
|
||||
- Email spoofing, SPF, DMARC & DKIM. Wekan does not include email server.
|
||||
- SSL BEAST/CRIME/etc. WeKan does not have SSL built-in; it uses Caddy/Nginx/Apache on the front end.
|
||||
|
||||
Wekan is Open Source with MIT license, and free to use also for commercial use.
|
||||
We welcome all fixes to improve security by email to security@wekan.fi
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v8.80.0"
|
||||
appVersion: "v9.32.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -25,19 +25,44 @@ syntax = """=== Wekan API Python CLI: Shows IDs for addcard ===
|
||||
# AUTHORID is USERID that writes card or custom field.
|
||||
If *nix: chmod +x api.py => ./api.py users
|
||||
Syntax:
|
||||
User API:
|
||||
User API:
|
||||
python3 api.py user # Current user and list of current user boards
|
||||
python3 api.py users # All users
|
||||
python3 api.py boards # All Public Boards
|
||||
python3 api.py boards USERID # Boards of USERID
|
||||
python3 api.py board BOARDID # Info of BOARDID
|
||||
|
||||
Board/List/Card API:
|
||||
python3 api.py swimlanes BOARDID # Swimlanes of BOARDID
|
||||
python3 api.py lists BOARDID # Lists of BOARDID
|
||||
python3 api.py list BOARDID LISTID # Info of LISTID
|
||||
python3 api.py createlist BOARDID LISTTITLE # Create list
|
||||
python3 api.py addcard AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION
|
||||
python3 api.py editcard BOARDID LISTID CARDID NEWCARDTITLE NEWCARDDESCRIPTION
|
||||
python3 api.py getcard BOARDID LISTID CARDID # Get card info
|
||||
python3 api.py cardsbyswimlane BOARDID SWIMLANEID # Retrieve cards list on a swimlane
|
||||
python3 api.py deleteallcards BOARDID SWIMLANEID ( * Be careful will delete ALL CARDS INSIDE the swimlanes automatically in every list * ) # Delete all cards on a swimlane
|
||||
python3 api.py get_list_cards_count BOARDID LISTID # Retrieve how many cards in a list
|
||||
python3 api.py get_board_cards_count BOARDID # Retrieve how many cards in a board
|
||||
python3 api.py editboardtitle BOARDID NEWBOARDTITLE # Edit board title
|
||||
python3 api.py copyboard BOARDID NEWBOARDTITLE # Copy a board
|
||||
|
||||
Custom Fields / Labels API:
|
||||
python3 api.py customfields BOARDID # Custom Fields of BOARDID
|
||||
python3 api.py customfield BOARDID CUSTOMFIELDID # Info of CUSTOMFIELDID
|
||||
python3 api.py addcustomfieldtoboard AUTHORID BOARDID NAME TYPE SETTINGS SHOWONCARD AUTOMATICALLYONCARD SHOWLABELONMINICARD SHOWSUMATTOPOFLIST # Add Custom Field to Board
|
||||
python3 api.py editcustomfield BOARDID LISTID CARDID CUSTOMFIELDID NEWCUSTOMFIELDVALUE # Edit Custom Field
|
||||
python3 api.py createlabel BOARDID LABELCOLOR LABELNAME (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Create a new label
|
||||
python3 api.py addlabel BOARDID LISTID CARDID LABELID # Add label to a card
|
||||
python3 api.py addcardwithlabel AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION LABELIDS # Add a card and a label
|
||||
python3 api.py editcardcolor BOARDID LISTID CARDID COLOR (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Edit card color
|
||||
|
||||
Checklist API:
|
||||
python3 api.py addchecklist BOARDID CARDID TITLE ITEM1 ITEM2 ITEM3 ITEM4 (You can add multiple items or just one, or also without any item, just TITLE works as well. * If items or Title contains spaces, you should add ' between them.) # Add checklist + item on a card
|
||||
python3 api.py checklistid BOARDID CARDID # Retrieve Checklist ID attached to a card
|
||||
python3 api.py checklistinfo BOARDID CARDID CHECKLISTID # Get checklist info
|
||||
|
||||
Attachment API:
|
||||
python3 api.py listattachments BOARDID # List attachments
|
||||
python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND] # Upload attachment to card
|
||||
python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH # Download attachment to local file
|
||||
@@ -45,25 +70,9 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID # List attachments for specific card
|
||||
python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move] # Copy or move attachment
|
||||
python3 api.py deleteattachment ATTACHMENTID # Delete attachment
|
||||
python3 api.py cardsbyswimlane SWIMLANEID LISTID # Retrieve cards list on a swimlane
|
||||
python3 api.py getcard BOARDID LISTID CARDID # Get card info
|
||||
python3 api.py addlabel BOARDID LISTID CARDID LABELID # Add label to a card
|
||||
python3 api.py addcardwithlabel AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION LABELIDS # Add a card and a label
|
||||
python3 api.py editboardtitle BOARDID NEWBOARDTITLE # Edit board title
|
||||
python3 api.py copyboard BOARDID NEWBOARDTITLE # Copy a board
|
||||
python3 api.py createlabel BOARDID LABELCOLOR LABELNAME (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Create a new label
|
||||
python3 api.py editcardcolor BOARDID LISTID CARDID COLOR (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Edit card color
|
||||
python3 api.py addchecklist BOARDID CARDID TITLE ITEM1 ITEM2 ITEM3 ITEM4 (You can add multiple items or just one, or also without any item, just TITLE works as well. * If items or Title contains spaces, you should add ' between them.) # Add checklist + item on a card
|
||||
python3 api.py deleteallcards BOARDID SWIMLANEID ( * Be careful will delete ALL CARDS INSIDE the swimlanes automatically in every list * ) # Delete all cards on a swimlane
|
||||
python3 api.py checklistid BOARDID CARDID # Retrieve Checklist ID attached to a card
|
||||
python3 api.py checklistinfo BOARDID CARDID CHECKLISTID # Get checklist info
|
||||
python3 api.py get_list_cards_count BOARDID LISTID # Retrieve how many cards in a list
|
||||
python3 api.py get_board_cards_count BOARDID # Retrieve how many cards in a board
|
||||
|
||||
|
||||
Admin API:
|
||||
python3 api.py users # All users
|
||||
python3 api.py boards # All Public Boards
|
||||
python3 api.py newuser USERNAME EMAIL PASSWORD
|
||||
"""
|
||||
|
||||
@@ -723,9 +732,9 @@ if arguments == 2:
|
||||
print(f"Error: {e}")
|
||||
# ------- GET BOARD CARDS COUNT END -----------
|
||||
|
||||
if arguments == 1:
|
||||
if arguments >= 1:
|
||||
|
||||
if sys.argv[1] == 'users':
|
||||
if arguments == 1 and sys.argv[1] == 'users':
|
||||
|
||||
# ------- LIST OF USERS START -----------
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -736,7 +745,7 @@ if arguments == 1:
|
||||
print(data2)
|
||||
# ------- LIST OF USERS END -----------
|
||||
|
||||
if sys.argv[1] == 'user':
|
||||
if arguments == 1 and sys.argv[1] == 'user':
|
||||
# ------- LIST OF ALL USERS START -----------
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
print(user)
|
||||
@@ -746,7 +755,7 @@ if arguments == 1:
|
||||
print(data2)
|
||||
# ------- LIST OF ALL USERS END -----------
|
||||
|
||||
if sys.argv[1] == 'boards':
|
||||
if arguments == 1 and sys.argv[1] == 'boards':
|
||||
|
||||
# ------- LIST OF PUBLIC BOARDS START -----------
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -761,7 +770,7 @@ if arguments == 1:
|
||||
|
||||
if sys.argv[1] == 'uploadattachment':
|
||||
# ------- UPLOAD ATTACHMENT START -----------
|
||||
if arguments < 5:
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND]")
|
||||
print("Storage backends: fs, gridfs, s3")
|
||||
exit(1)
|
||||
|
||||
+83
-2
@@ -1,7 +1,41 @@
|
||||
// PWA
|
||||
// Fix dynamic-import chunk loading when Wekan runs under a sub-path (ROOT_URL includes a pathname,
|
||||
// e.g. https://example.com/wekan). Rspack uses __webpack_public_path__ to prefix chunk URLs; by
|
||||
// default it is set to /build-chunks/ (no context path). We update it here, at module evaluation
|
||||
// time, before any import() expression runs (those are deferred to Meteor.startup callbacks).
|
||||
/* global __webpack_public_path__:writable */
|
||||
try {
|
||||
const _cfg = typeof window !== 'undefined' && window.__meteor_runtime_config__;
|
||||
const _rootUrl = _cfg && _cfg.ROOT_URL;
|
||||
if (_rootUrl) {
|
||||
const _rootPath = new URL(_rootUrl).pathname.replace(/\/+$/, '');
|
||||
if (_rootPath && _rootPath !== '/') {
|
||||
__webpack_public_path__ = _rootPath + '/build-chunks/';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Fix bug in jam:offline 0.4.1: s?.message.includes() crashes when s.message is
|
||||
// undefined (e.g. during WebSocket reconnect errors that don't carry a .message).
|
||||
// Wrap _debug so the optional chain is complete: s?.message?.includes().
|
||||
(function patchJamOfflineDebug() {
|
||||
const patched = Meteor._debug;
|
||||
Meteor._debug = function (m, s) {
|
||||
try {
|
||||
return patched.call(this, m, s);
|
||||
} catch (e) {
|
||||
// jam:offline's override threw — fall back to the bare console log.
|
||||
if (typeof console !== 'undefined') {
|
||||
console.log(m, s);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// PWA — use Meteor.absoluteUrl so the path is correct under sub-URL deployments
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/pwa-service-worker.js');
|
||||
const swPath = new URL('pwa-service-worker.js', Meteor.absoluteUrl()).pathname;
|
||||
navigator.serviceWorker.register(swPath);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +60,22 @@ import { Utils } from '/client/lib/utils';
|
||||
// Token already lives in localStorage; cookie adds same-origin send-on-request semantics
|
||||
Meteor.startup(() => {
|
||||
const COOKIE_NAME = 'meteor_login_token';
|
||||
const USER_ID_COOKIE = 'meteor_user_id';
|
||||
const TOKEN_EXPIRES_COOKIE = 'meteor_login_token_expires';
|
||||
|
||||
const getCookie = (name) => {
|
||||
try {
|
||||
const parts = document.cookie ? document.cookie.split(';') : [];
|
||||
for (const part of parts) {
|
||||
const [k, ...rest] = part.trim().split('=');
|
||||
if (decodeURIComponent(k) === name) {
|
||||
return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
};
|
||||
|
||||
const cookieAttrs = () => {
|
||||
const attrs = ['Path=/', 'SameSite=Lax'];
|
||||
try {
|
||||
@@ -53,6 +103,37 @@ Meteor.startup(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrapTokenFromCookie = () => {
|
||||
try {
|
||||
const currentToken = Accounts && typeof Accounts._storedLoginToken === 'function'
|
||||
? Accounts._storedLoginToken()
|
||||
: null;
|
||||
if (currentToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getCookie(COOKIE_NAME);
|
||||
const userId = getCookie(USER_ID_COOKIE);
|
||||
const tokenExpiresRaw = getCookie(TOKEN_EXPIRES_COOKIE);
|
||||
const tokenExpires = tokenExpiresRaw ? new Date(tokenExpiresRaw) : null;
|
||||
|
||||
if (!token || !userId || !tokenExpires || Number.isNaN(tokenExpires.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Accounts && typeof Accounts._storeLoginToken === 'function') {
|
||||
Accounts._storeLoginToken(userId, token, tokenExpires);
|
||||
if (Meteor.status && !Meteor.status().connected && typeof Meteor.reconnect === 'function') {
|
||||
Meteor.reconnect();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
bootstrapTokenFromCookie();
|
||||
|
||||
// Initial sync on startup
|
||||
syncCookie();
|
||||
|
||||
|
||||
@@ -258,14 +258,18 @@ Template.activity.helpers({
|
||||
|
||||
attachmentLink() {
|
||||
const attachment = this.activity.attachment();
|
||||
const attachmentUrl = attachment && typeof attachment.link === 'function'
|
||||
? attachment.link()
|
||||
: '';
|
||||
// trying to display url before file is stored generates js errors
|
||||
return (
|
||||
(attachment &&
|
||||
attachment.path &&
|
||||
attachmentUrl &&
|
||||
Blaze.toHTML(
|
||||
HTML.A(
|
||||
{
|
||||
href: `${attachment.link()}?download=true`,
|
||||
href: `${attachmentUrl}?download=true`,
|
||||
target: '_blank',
|
||||
},
|
||||
sanitizeText(attachment.name),
|
||||
@@ -290,7 +294,8 @@ Template.activity.helpers({
|
||||
|
||||
Template.commentReactions.events({
|
||||
'click .reaction'(event) {
|
||||
if (ReactiveCache.getCurrentUser().isBoardMember()) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user && user.isBoardMember()) {
|
||||
const codepoint = event.currentTarget.dataset['codepoint'];
|
||||
const commentId = Template.instance().data.commentId;
|
||||
const cardComment = ReactiveCache.getCardComment(commentId);
|
||||
@@ -302,7 +307,8 @@ Template.commentReactions.events({
|
||||
|
||||
Template.addReactionPopup.events({
|
||||
'click .add-comment-reaction'(event) {
|
||||
if (ReactiveCache.getCurrentUser().isBoardMember()) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user && user.isBoardMember()) {
|
||||
const codepoint = event.currentTarget.dataset['codepoint'];
|
||||
const commentId = Template.instance().data.commentId;
|
||||
const cardComment = ReactiveCache.getCardComment(commentId);
|
||||
|
||||
@@ -70,7 +70,7 @@ Template.comment.events({
|
||||
evt.preventDefault();
|
||||
const textarea = tpl.find('.js-edit-comment textarea,input[type=text]');
|
||||
const commentText = textarea && textarea.value ? textarea.value.trim() : '';
|
||||
const commentId = this._id;
|
||||
const commentId = tpl.data._id;
|
||||
if (commentText) {
|
||||
CardComments.update(commentId, {
|
||||
$set: {
|
||||
|
||||
@@ -142,6 +142,7 @@ Template.board.helpers({
|
||||
|
||||
Template.boardBody.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
Meteor.subscribe('inviteToBoardRolesSettings');
|
||||
this.showOverlay = new ReactiveVar(false);
|
||||
this.draggingActive = new ReactiveVar(false);
|
||||
this._isDragging = false;
|
||||
@@ -606,6 +607,17 @@ Template.boardBody.onDestroyed(function () {
|
||||
});
|
||||
|
||||
Template.boardBody.helpers({
|
||||
openCards() {
|
||||
const isMobile = Utils.getMobileMode();
|
||||
if (!isMobile) {
|
||||
const openCardIds = Session.get('openCards') || [];
|
||||
return openCardIds
|
||||
.map((id) => ReactiveCache.getCard(id))
|
||||
.filter((card) => card);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
draggingActive() {
|
||||
return Template.instance().draggingActive.get();
|
||||
},
|
||||
|
||||
@@ -3370,18 +3370,19 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight .list,
|
||||
.board-color-cleandark .list {
|
||||
background: none;
|
||||
border-left: none;
|
||||
background: none !important;
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .list .list-header,
|
||||
.board-color-cleandark .list .list-header {
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
border-bottom: none !important;
|
||||
display: flex !important;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
background: none;
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .list .list-header div:has(.list-header-name),
|
||||
@@ -4279,6 +4280,14 @@ THEME - Clean Light
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.board-color-cleandark .pop-over-list li > a {
|
||||
color: rgba(255, 255, 255, 1) !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .pop-over-list li > a {
|
||||
color: rgba(10, 10, 20, 0.85) !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .pop-over-list li > a i,
|
||||
.board-color-cleandark .pop-over-list li > a i {
|
||||
margin-right: 6px !important;
|
||||
|
||||
@@ -1,151 +1,152 @@
|
||||
template(name="boardHeaderBar")
|
||||
h1.header-board-menu
|
||||
with currentBoard
|
||||
if $eq title 'Templates'
|
||||
| {{_ 'templates'}}
|
||||
else
|
||||
+viewer
|
||||
= title
|
||||
unless notDisplayThisBoard
|
||||
h1.header-board-menu
|
||||
with currentBoard
|
||||
if $eq title 'Templates'
|
||||
| {{_ 'templates'}}
|
||||
else
|
||||
+viewer
|
||||
= title
|
||||
|
||||
.board-header-btns.left
|
||||
unless isMiniScreen
|
||||
.board-header-btns.left
|
||||
unless isMiniScreen
|
||||
if currentBoard
|
||||
if currentUser
|
||||
with currentBoard
|
||||
if currentUser.isBoardAdmin
|
||||
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
|
||||
i.fa.fa-pencil-square-o
|
||||
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
span {{_ currentBoard.permission}}
|
||||
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
span {{_ watchLevel}}
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
if showStarCounter
|
||||
span.board-star-counter {{currentBoard.stars}}
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
span {{_ 'log-in'}}
|
||||
|
||||
.board-header-btns.center
|
||||
|
||||
.board-header-btns.right
|
||||
if currentBoard
|
||||
if currentUser
|
||||
with currentBoard
|
||||
if currentUser.isBoardAdmin
|
||||
if isMiniScreen
|
||||
if currentUser
|
||||
with currentBoard
|
||||
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
|
||||
i.fa.fa-pencil-square-o
|
||||
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
span {{_ currentBoard.permission}}
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
span {{_ watchLevel}}
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
if showStarCounter
|
||||
span.board-star-counter {{currentBoard.stars}}
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
|
||||
i.fa.fa-times-thin
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
span {{_ 'log-in'}}
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
|
||||
.board-header-btns.center
|
||||
if isSandstorm
|
||||
if currentUser
|
||||
a.board-header-btn.js-open-archived-board
|
||||
i.fa.fa-archive
|
||||
|
||||
.board-header-btns.right
|
||||
if currentBoard
|
||||
if isMiniScreen
|
||||
if currentUser
|
||||
with currentBoard
|
||||
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
|
||||
i.fa.fa-pencil-square-o
|
||||
//if showSort
|
||||
//
|
||||
a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
|
||||
//
|
||||
i.fa(class="{{directionClass}}")
|
||||
//
|
||||
span {{_ 'sort'}}{{_ listSortShortDesc}}
|
||||
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
|
||||
if isSandstorm
|
||||
if currentUser
|
||||
a.board-header-btn.js-open-archived-board
|
||||
i.fa.fa-archive
|
||||
|
||||
//if showSort
|
||||
//
|
||||
a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
|
||||
//
|
||||
i.fa(class="{{directionClass}}")
|
||||
//
|
||||
span {{_ 'sort'}}{{_ listSortShortDesc}}
|
||||
|
||||
a.board-header-btn.js-open-filter-view(
|
||||
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
|
||||
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
|
||||
i.fa.fa-filter
|
||||
span {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
|
||||
if Filter.isActive
|
||||
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
|
||||
i.fa.fa-search
|
||||
span {{_ 'search'}}
|
||||
|
||||
unless currentBoard.isTemplatesBoard
|
||||
a.board-header-btn.js-toggle-board-view
|
||||
i.fa.fa-caret-down
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
i.fa.fa-th-large
|
||||
if $eq boardView 'board-view-lists'
|
||||
i.fa.fa-trello
|
||||
if $eq boardView 'board-view-cal'
|
||||
i.fa.fa-calendar
|
||||
if $eq boardView 'board-view-gantt'
|
||||
i.fa.fa-bar-chart
|
||||
span
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
| {{_ 'swimlanes'}}
|
||||
if $eq boardView 'board-view-lists'
|
||||
| {{_ 'lists'}}
|
||||
if $eq boardView 'board-view-cal'
|
||||
| {{_ 'calendar'}}
|
||||
if $eq boardView 'board-view-gantt'
|
||||
| {{_ 'gantt'}}
|
||||
if canModifyBoard
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
|
||||
i.fa.fa-check-square-o
|
||||
span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
|
||||
if MultiSelection.isActive
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
a.board-header-btn.js-open-filter-view(
|
||||
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
|
||||
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
|
||||
i.fa.fa-filter
|
||||
span {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
|
||||
if Filter.isActive
|
||||
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
.separator
|
||||
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
|
||||
i.fa.fa-bars
|
||||
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
|
||||
i.fa.fa-search
|
||||
span {{_ 'search'}}
|
||||
|
||||
unless currentBoard.isTemplatesBoard
|
||||
a.board-header-btn.js-toggle-board-view
|
||||
i.fa.fa-caret-down
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
i.fa.fa-th-large
|
||||
if $eq boardView 'board-view-lists'
|
||||
i.fa.fa-trello
|
||||
if $eq boardView 'board-view-cal'
|
||||
i.fa.fa-calendar
|
||||
if $eq boardView 'board-view-gantt'
|
||||
i.fa.fa-bar-chart
|
||||
span
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
| {{_ 'swimlanes'}}
|
||||
if $eq boardView 'board-view-lists'
|
||||
| {{_ 'lists'}}
|
||||
if $eq boardView 'board-view-cal'
|
||||
| {{_ 'calendar'}}
|
||||
if $eq boardView 'board-view-gantt'
|
||||
| {{_ 'gantt'}}
|
||||
if canModifyBoard
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
|
||||
i.fa.fa-check-square-o
|
||||
span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
|
||||
if MultiSelection.isActive
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
.separator
|
||||
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
|
||||
i.fa.fa-bars
|
||||
|
||||
template(name="boardVisibilityList")
|
||||
ul.pop-over-list
|
||||
|
||||
@@ -39,7 +39,22 @@ Template.boardChangeTitlePopup.events({
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardHeaderBar.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
});
|
||||
|
||||
Template.boardHeaderBar.helpers({
|
||||
notDisplayThisBoard() {
|
||||
const allowPrivateVisibilityOnly = TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly');
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
return (
|
||||
allowPrivateVisibilityOnly !== undefined &&
|
||||
allowPrivateVisibilityOnly.booleanValue &&
|
||||
currentBoard &&
|
||||
currentBoard.permission === 'public'
|
||||
);
|
||||
},
|
||||
|
||||
watchLevel() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
return currentBoard && currentBoard.getWatchLevel(Meteor.userId());
|
||||
@@ -218,7 +233,7 @@ function createBoardHelpers() {
|
||||
return Template.instance().visibility.get();
|
||||
},
|
||||
notAllowPrivateVisibilityOnly() {
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly').booleanValue;
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
|
||||
},
|
||||
visibilityCheck() {
|
||||
return Template.currentData() === Template.instance().visibility.get();
|
||||
@@ -288,7 +303,7 @@ async function createBoardSubmit(tpl, event) {
|
||||
function createBoardEvents() {
|
||||
return {
|
||||
'click .js-select-visibility'(event, tpl) {
|
||||
tpl.visibility.set(Template.currentData());
|
||||
tpl.visibility.set(this);
|
||||
tpl.visibilityMenuIsOpen.set(false);
|
||||
},
|
||||
'click .js-change-visibility'(event, tpl) {
|
||||
@@ -347,7 +362,7 @@ Template.headerBarCreateBoardPopup.helpers(createBoardHelpers());
|
||||
|
||||
Template.headerBarCreateBoardPopup.events({
|
||||
'click .js-select-visibility'(event, tpl) {
|
||||
tpl.visibility.set(Template.currentData());
|
||||
tpl.visibility.set(this);
|
||||
tpl.visibilityMenuIsOpen.set(false);
|
||||
},
|
||||
'click .js-change-visibility'(event, tpl) {
|
||||
@@ -366,13 +381,19 @@ Template.headerBarCreateBoardPopup.events({
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardVisibilityList.helpers({
|
||||
notAllowPrivateVisibilityOnly() {
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardChangeVisibilityPopup.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
});
|
||||
|
||||
Template.boardChangeVisibilityPopup.helpers({
|
||||
notAllowPrivateVisibilityOnly(){
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly').booleanValue;
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
|
||||
},
|
||||
visibilityCheck() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
@@ -383,7 +404,7 @@ Template.boardChangeVisibilityPopup.helpers({
|
||||
Template.boardChangeVisibilityPopup.events({
|
||||
'click .js-select-visibility'() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const visibility = Template.currentData();
|
||||
const visibility = this;
|
||||
if (typeof visibility === 'string') {
|
||||
currentBoard.setVisibility(visibility);
|
||||
Popup.back();
|
||||
@@ -405,7 +426,7 @@ Template.boardChangeWatchPopup.helpers({
|
||||
|
||||
Template.boardChangeWatchPopup.events({
|
||||
'click .js-select-watch'() {
|
||||
const level = Template.currentData();
|
||||
const level = this;
|
||||
if (typeof level === 'string') {
|
||||
Meteor.call(
|
||||
'watch',
|
||||
|
||||
@@ -62,6 +62,17 @@
|
||||
border: 2px dashed #2196F3;
|
||||
}
|
||||
|
||||
/* Highlight valid drop targets while a board is being dragged */
|
||||
.workspace-node.board-drag-hint > .workspace-node-content {
|
||||
background: #e8f5e9;
|
||||
border: 1px dashed #4CAF50;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.boards-left-menu .menu-item a.board-drag-hint {
|
||||
background: #e8f5e9;
|
||||
border: 1px dashed #4CAF50;
|
||||
}
|
||||
|
||||
.workspaces-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -130,7 +130,7 @@ template(name="boardList")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
span.board-handle(title="{{_ 'drag-board'}}")
|
||||
span.board-handle(title="{{boardWorkspaceDragHint}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
|
||||
@@ -158,13 +158,13 @@ template(name="boardList")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
span.board-handle(title="{{_ 'drag-board'}}")
|
||||
span.board-handle(title="{{boardWorkspaceDragHint}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
|
||||
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
|
||||
span.details
|
||||
span.board-list-item-name(title="{{_ 'board-drag-drop-reorder-or-click-open'}}")
|
||||
span.board-list-item-name(title="{{boardOpenAndMoveHint}}")
|
||||
+viewer
|
||||
= title
|
||||
unless currentSetting.hideBoardMemberList
|
||||
|
||||
@@ -354,12 +354,6 @@ Template.boardList.helpers({
|
||||
if (FlowRouter.getRouteName() === 'home') {
|
||||
membershipOrs.push({ 'members.userId': Meteor.userId() });
|
||||
|
||||
if (
|
||||
allowPrivateVisibilityOnly !== undefined &&
|
||||
allowPrivateVisibilityOnly.booleanValue
|
||||
) {
|
||||
query.$and.push({ permission: 'private' });
|
||||
}
|
||||
const currUser = ReactiveCache.getCurrentUser();
|
||||
|
||||
let orgIdsUserBelongs = currUser?.orgIdsUserBelongs() || '';
|
||||
@@ -539,6 +533,28 @@ Template.boardList.helpers({
|
||||
hasBoardsSelected() {
|
||||
return BoardMultiSelection.count() > 0;
|
||||
},
|
||||
boardWorkspaceDragHint() {
|
||||
const remaining = TAPi18n.__('allboards.remaining') || 'Remaining';
|
||||
const workspaces = TAPi18n.__('allboards.workspaces') || 'Workspaces';
|
||||
return (
|
||||
TAPi18n.__('drag-board-to-workspace', { remaining, workspaces }) ||
|
||||
`Drag board to assign to ${workspaces} (drop on workspace in left sidebar)`
|
||||
);
|
||||
},
|
||||
boardOpenAndMoveHint() {
|
||||
const remaining = TAPi18n.__('allboards.remaining') || 'Remaining';
|
||||
const workspaces = TAPi18n.__('allboards.workspaces') || 'Workspaces';
|
||||
return (
|
||||
TAPi18n.__(
|
||||
'board-open-and-move-between-remaining-and-workspaces',
|
||||
{
|
||||
remaining,
|
||||
workspaces,
|
||||
},
|
||||
) ||
|
||||
`Click board icon to open board. Drag board between ${remaining} and ${workspaces}.`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Template.workspaceTree.helpers({
|
||||
@@ -642,6 +658,20 @@ Template.boardList.events({
|
||||
evt.originalEvent.dataTransfer.setData('text/plain', boardId);
|
||||
} catch (e) {}
|
||||
}
|
||||
// Highlight valid drop targets in the sidebar so users know where to drop
|
||||
document.querySelectorAll('.workspace-node').forEach((el) => {
|
||||
el.classList.add('board-drag-hint');
|
||||
});
|
||||
document.querySelectorAll('.js-select-menu').forEach((el) => {
|
||||
if (el.getAttribute('data-type') === 'remaining') {
|
||||
el.classList.add('board-drag-hint');
|
||||
}
|
||||
});
|
||||
},
|
||||
'dragend .js-board'() {
|
||||
document.querySelectorAll('.workspace-node.board-drag-hint, .js-select-menu.board-drag-hint').forEach((el) => {
|
||||
el.classList.remove('board-drag-hint');
|
||||
});
|
||||
},
|
||||
'click .js-clone-board'(evt) {
|
||||
if (confirm(TAPi18n.__('duplicate-board-confirm'))) {
|
||||
@@ -675,7 +705,9 @@ Template.boardList.events({
|
||||
'click .js-archive-board'(evt) {
|
||||
if (confirm(TAPi18n.__('archive-board-confirm'))) {
|
||||
const boardId = this._id;
|
||||
Meteor.call('archiveBoard', boardId);
|
||||
Meteor.call('archiveBoard', boardId, (err) => {
|
||||
if (err) alert(err?.reason || err?.message || 'Failed to archive board');
|
||||
});
|
||||
evt.preventDefault();
|
||||
}
|
||||
},
|
||||
@@ -718,7 +750,9 @@ Template.boardList.events({
|
||||
confirm(TAPi18n.__('archive-board-confirm'))
|
||||
) {
|
||||
selectedBoards.forEach((boardId) => {
|
||||
Meteor.call('archiveBoard', boardId);
|
||||
Meteor.call('archiveBoard', boardId, (err) => {
|
||||
if (err) alert(err?.reason || err?.message || 'Failed to archive board');
|
||||
});
|
||||
});
|
||||
BoardMultiSelection.reset();
|
||||
}
|
||||
|
||||
@@ -125,23 +125,6 @@ template(name="attachmentActionsPopup")
|
||||
else
|
||||
| {{_ 'add-background-image'}}
|
||||
|
||||
if $neq versions.original.storage "fs"
|
||||
a.js-move-storage-fs
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-fs'}}
|
||||
|
||||
if $neq versions.original.storage "gridfs"
|
||||
if versions.original.storage
|
||||
a.js-move-storage-gridfs
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-gridfs'}}
|
||||
|
||||
if $neq versions.original.storage "s3"
|
||||
if versions.original.storage
|
||||
a.js-move-storage-s3
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-s3'}}
|
||||
|
||||
template(name="attachmentRenamePopup")
|
||||
input.js-edit-attachment-name(type='text' autofocus value="{{getNameWithoutExtension}}" dir="auto")
|
||||
.edit-controls.clearfix
|
||||
|
||||
@@ -7,6 +7,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
|
||||
import Attachments from '/models/attachments';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { formatDateTime } from '/imports/lib/dateUtils';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
|
||||
const { filesize } = require('filesize');
|
||||
import prettyMilliseconds from 'pretty-ms';
|
||||
@@ -44,6 +45,10 @@ Template.attachmentGallery.events({
|
||||
},
|
||||
'click .js-rename': Popup.open('attachmentRename'),
|
||||
'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', async function() {
|
||||
const card = this.meta && this.meta.cardId ? ReactiveCache.getCard(this.meta.cardId) : null;
|
||||
if (card && card.coverId === this._id) {
|
||||
await card.unsetCover();
|
||||
}
|
||||
await Attachments.removeAsync(this._id);
|
||||
Popup.back();
|
||||
}),
|
||||
@@ -84,6 +89,10 @@ function attachmentCanBeOpened(attachment) {
|
||||
);
|
||||
}
|
||||
|
||||
function getAttachmentUrl(attachment) {
|
||||
return attachment && typeof attachment.link === 'function' ? attachment.link() : '';
|
||||
}
|
||||
|
||||
function openAttachmentViewer(attachmentId) {
|
||||
const attachment = ReactiveCache.getAttachment(attachmentId);
|
||||
|
||||
@@ -100,18 +109,18 @@ function openAttachmentViewer(attachmentId) {
|
||||
*/
|
||||
switch(true){
|
||||
case (attachment.isImage):
|
||||
$("#image-viewer").attr("src", attachment.link());
|
||||
$("#image-viewer").attr("src", getAttachmentUrl(attachment));
|
||||
$("#image-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (attachment.isPDF):
|
||||
$("#pdf-viewer").attr("data", attachment.link());
|
||||
$("#pdf-viewer").attr("data", getAttachmentUrl(attachment));
|
||||
$("#pdf-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (attachment.isVideo):
|
||||
// We have to create a new <source> DOM element and append it to the video
|
||||
// element, otherwise the video won't load
|
||||
let videoSource = document.createElement('source');
|
||||
videoSource.setAttribute('src', attachment.link());
|
||||
videoSource.setAttribute('src', getAttachmentUrl(attachment));
|
||||
$("#video-viewer").append(videoSource);
|
||||
|
||||
$("#video-viewer").removeClass("hidden");
|
||||
@@ -120,14 +129,14 @@ function openAttachmentViewer(attachmentId) {
|
||||
// We have to create a new <source> DOM element and append it to the audio
|
||||
// element, otherwise the audio won't load
|
||||
let audioSource = document.createElement('source');
|
||||
audioSource.setAttribute('src', attachment.link());
|
||||
audioSource.setAttribute('src', getAttachmentUrl(attachment));
|
||||
$("#audio-viewer").append(audioSource);
|
||||
|
||||
$("#audio-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (attachment.isText):
|
||||
case (attachment.isJSON):
|
||||
$("#txt-viewer").attr("data", attachment.link());
|
||||
$("#txt-viewer").attr("data", getAttachmentUrl(attachment));
|
||||
$("#txt-viewer").removeClass("hidden");
|
||||
break;
|
||||
}
|
||||
@@ -262,6 +271,24 @@ Template.attachmentViewer.events({
|
||||
});
|
||||
|
||||
Template.attachmentGallery.helpers({
|
||||
attachments() {
|
||||
const card = Template.currentData();
|
||||
if (!card) return [];
|
||||
const cardId = typeof card.getRealId === 'function' ? card.getRealId() : card._id;
|
||||
if (!cardId) return [];
|
||||
const filesCursor = Attachments.find(
|
||||
{ 'meta.cardId': cardId },
|
||||
{ sort: { uploadedAt: -1 } },
|
||||
);
|
||||
// Call fetch() on the underlying Mongo cursor to establish a reactive
|
||||
// dependency directly inside this Blaze computation. Without this,
|
||||
// .each() (which calls cursor.map()) would not register reactivity and
|
||||
// newly-uploaded attachments would not appear until a page reload.
|
||||
if (filesCursor && filesCursor.cursor) {
|
||||
filesCursor.cursor.fetch();
|
||||
}
|
||||
return filesCursor.each();
|
||||
},
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
},
|
||||
@@ -314,6 +341,7 @@ Template.cardAttachmentsPopup.helpers({
|
||||
|
||||
Template.cardAttachmentsPopup.events({
|
||||
async 'change .js-attach-file'(event, templateInstance) {
|
||||
event.stopPropagation();
|
||||
const card = this;
|
||||
const files = event.currentTarget.files;
|
||||
if (files) {
|
||||
@@ -336,6 +364,10 @@ Template.cardAttachmentsPopup.events({
|
||||
}
|
||||
},
|
||||
'click .js-computer-upload'(event, templateInstance) {
|
||||
// Prevent the click fired when the OS file-picker dialog closes from
|
||||
// triggering EscapeActions and closing the popup before the upload starts.
|
||||
// Same pattern as swimlanes.js after drag operations.
|
||||
EscapeActions.preventNextClick();
|
||||
templateInstance.find('.js-attach-file').click();
|
||||
event.preventDefault();
|
||||
},
|
||||
@@ -400,6 +432,11 @@ export async function handleFileUpload(card, files) {
|
||||
fileName: fileName,
|
||||
meta: Utils.getCommonAttachmentMetaFrom(card),
|
||||
chunkSize: 'dynamic',
|
||||
// Use HTTP transport instead of DDP so file chunks go over a dedicated
|
||||
// fetch POST rather than flooding the WebSocket/DDP channel. DDP
|
||||
// upload causes repeated DDP reconnects in Safari, showing the
|
||||
// "Loading, please wait" offline banner and stalling progress at ~95%.
|
||||
transport: 'http',
|
||||
};
|
||||
config.meta.fileId = fileId;
|
||||
|
||||
@@ -491,6 +528,7 @@ Template.previewClipboardImagePopup.events({
|
||||
meta: Utils.getCommonAttachmentMetaFrom(card),
|
||||
fileName: file.name || file.type.replace('image/', 'clipboard.'),
|
||||
chunkSize: 'dynamic',
|
||||
transport: 'http',
|
||||
};
|
||||
config.meta.fileId = fileId;
|
||||
const uploader = await Attachments.insertAsync(
|
||||
@@ -550,23 +588,11 @@ Template.attachmentActionsPopup.events({
|
||||
Utils.reload();
|
||||
event.preventDefault();
|
||||
},
|
||||
'click .js-move-storage-fs'() {
|
||||
Meteor.call('moveAttachmentToStorage', this._id, "fs");
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-move-storage-gridfs'() {
|
||||
Meteor.call('moveAttachmentToStorage', this._id, "gridfs");
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-move-storage-s3'() {
|
||||
Meteor.call('moveAttachmentToStorage', this._id, "s3");
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.attachmentRenamePopup.helpers({
|
||||
getNameWithoutExtension() {
|
||||
const ret = this.name.replace(new RegExp("\." + this.extension + "$"), "");
|
||||
const ret = this.name.replace(new RegExp("\\." + this.extension + "$"), "");
|
||||
return ret;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -170,7 +170,7 @@ Template['cardCustomField-date'].helpers({
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showISODate() {
|
||||
|
||||
@@ -146,14 +146,12 @@ function cardDateHelpers(extraHelpers) {
|
||||
},
|
||||
showWeekOfYear() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
if (!user) return window.localStorage.getItem('showWeekOfYear') === 'true';
|
||||
return user.isShowWeekOfYear();
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showISODate() {
|
||||
@@ -196,7 +194,7 @@ Template.cardReceivedDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
|
||||
},
|
||||
@@ -237,7 +235,7 @@ Template.cardStartDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
|
||||
},
|
||||
@@ -286,7 +284,7 @@ Template.cardDueDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-due-on')} ${formattedDate}`;
|
||||
},
|
||||
@@ -356,7 +354,7 @@ Template.cardCustomFieldDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${formattedDate}`;
|
||||
},
|
||||
@@ -400,13 +398,13 @@ Template.minicardReceivedDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
}));
|
||||
@@ -446,13 +444,13 @@ Template.minicardStartDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
}));
|
||||
@@ -500,13 +498,13 @@ Template.minicardDueDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-due-on')} ${formattedDate}`;
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
}));
|
||||
@@ -549,7 +547,7 @@ Template.minicardEndDate.helpers(cardDateHelpers({
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
}));
|
||||
@@ -570,13 +568,13 @@ Template.minicardCustomFieldDate.onCreated(function () {
|
||||
Template.minicardCustomFieldDate.helpers(cardDateHelpers({
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${formattedDate}`;
|
||||
},
|
||||
@@ -602,7 +600,7 @@ Template.voteEndDate.helpers(cardDateHelpers({
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showTitle() {
|
||||
@@ -630,7 +628,7 @@ Template.pokerEndDate.helpers(cardDateHelpers({
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showTitle() {
|
||||
|
||||
@@ -66,7 +66,7 @@ template(name="cardDetails")
|
||||
+viewer
|
||||
if currentBoard.allowsCardNumber
|
||||
span.card-number
|
||||
| ##{getCardNumber}
|
||||
| ##{getCardNumber}
|
||||
= getTitle
|
||||
if isWatching
|
||||
i.card-details-watch
|
||||
@@ -297,7 +297,7 @@ template(name="cardDetails")
|
||||
i.fa.fa-list
|
||||
| {{_ 'list'}}
|
||||
select.js-select-card-details-lists(disabled="{{#unless canModifyCard}}disabled{{/unless}}")
|
||||
each currentBoard.lists
|
||||
each currentSwimlaneListsSorted
|
||||
option(value="{{_id}}" selected="{{#if isCurrentListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
//.card-details-items
|
||||
@@ -624,19 +624,18 @@ template(name="cardDetails")
|
||||
+attachmentGallery
|
||||
hr
|
||||
|
||||
unless currentUser.isNoComments
|
||||
if currentBoard.allowsComments
|
||||
.comment-title
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-comment-o
|
||||
| {{_ 'comments'}}
|
||||
|
||||
if currentBoard.allowsComments
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isNoComments
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
+commentForm
|
||||
+comments
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isNoComments
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
+commentForm
|
||||
+comments
|
||||
hr
|
||||
|
||||
.card-details-right
|
||||
@@ -691,154 +690,156 @@ template(name="editCardSortOrderForm")
|
||||
a.js-close-inlined-form
|
||||
|
||||
template(name="cardDetailsActionsPopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-toggle-watch-card
|
||||
if isWatching
|
||||
i.fa.fa-eye
|
||||
| {{_ 'unwatch'}}
|
||||
else
|
||||
i.fa.fa-eye
|
||||
| {{_ 'watch'}}
|
||||
hr
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
//li: a.js-members {{_ 'card-edit-members'}}
|
||||
//li: a.js-labels {{_ 'card-edit-labels'}}
|
||||
//li: a.js-attachments {{_ 'card-edit-attachments'}}
|
||||
li
|
||||
a.js-start-voting
|
||||
i.fa.fa-thumbs-up
|
||||
| {{_ 'card-edit-voting'}}
|
||||
li
|
||||
a.js-start-planning-poker
|
||||
i.fa.fa-thumbs-up
|
||||
| {{_ 'card-edit-planning-poker'}}
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-custom-fields
|
||||
i.fa.fa-list
|
||||
| {{_ 'card-edit-custom-fields'}}
|
||||
//li: a.js-received-date {{_ 'editCardReceivedDatePopup-title'}}
|
||||
//li: a.js-start-date {{_ 'editCardStartDatePopup-title'}}
|
||||
//li: a.js-due-date {{_ 'editCardDueDatePopup-title'}}
|
||||
//li: a.js-end-date {{_ 'editCardEndDatePopup-title'}}
|
||||
li
|
||||
a.js-spent-time
|
||||
i.fa.fa-clock-o
|
||||
| {{_ 'editCardSpentTimePopup-title'}}
|
||||
li
|
||||
a.js-set-card-color
|
||||
i.fa.fa-paint-brush
|
||||
| {{_ 'setCardColorPopup-title'}}
|
||||
li
|
||||
a.js-toggle-show-list-on-minicard
|
||||
if showListOnMinicard
|
||||
i.fa.fa-eye
|
||||
| {{_ 'hide-list-on-minicard'}}
|
||||
else
|
||||
i.fa.fa-eye
|
||||
| {{_ 'show-list-on-minicard'}}
|
||||
if canModifyCard
|
||||
if currentUser
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-toggle-watch-card
|
||||
if isWatching
|
||||
i.fa.fa-eye
|
||||
| {{_ 'unwatch'}}
|
||||
else
|
||||
i.fa.fa-eye
|
||||
| {{_ 'watch'}}
|
||||
hr
|
||||
else
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
hr
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
//li: a.js-members {{_ 'card-edit-members'}}
|
||||
//li: a.js-labels {{_ 'card-edit-labels'}}
|
||||
//li: a.js-attachments {{_ 'card-edit-attachments'}}
|
||||
li
|
||||
a.js-start-voting
|
||||
i.fa.fa-thumbs-up
|
||||
| {{_ 'card-edit-voting'}}
|
||||
li
|
||||
a.js-start-planning-poker
|
||||
i.fa.fa-thumbs-up
|
||||
| {{_ 'card-edit-planning-poker'}}
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-custom-fields
|
||||
i.fa.fa-list
|
||||
| {{_ 'card-edit-custom-fields'}}
|
||||
//li: a.js-received-date {{_ 'editCardReceivedDatePopup-title'}}
|
||||
//li: a.js-start-date {{_ 'editCardStartDatePopup-title'}}
|
||||
//li: a.js-due-date {{_ 'editCardDueDatePopup-title'}}
|
||||
//li: a.js-end-date {{_ 'editCardEndDatePopup-title'}}
|
||||
li
|
||||
a.js-spent-time
|
||||
i.fa.fa-clock-o
|
||||
| {{_ 'editCardSpentTimePopup-title'}}
|
||||
li
|
||||
a.js-set-card-color
|
||||
i.fa.fa-paint-brush
|
||||
| {{_ 'setCardColorPopup-title'}}
|
||||
li
|
||||
a.js-toggle-show-list-on-minicard
|
||||
if showListOnMinicard
|
||||
i.fa.fa-eye
|
||||
| {{_ 'hide-list-on-minicard'}}
|
||||
else
|
||||
i.fa.fa-eye
|
||||
| {{_ 'show-list-on-minicard'}}
|
||||
if canModifyCard
|
||||
hr
|
||||
else
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-export-card
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export-card'}}
|
||||
unless canModifyCard
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-move-card-to-top
|
||||
i.fa.fa-arrow-up
|
||||
| {{_ 'moveCardToTop-title'}}
|
||||
li
|
||||
a.js-move-card-to-bottom
|
||||
i.fa.fa-arrow-down
|
||||
| {{_ 'moveCardToBottom-title'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-move-card
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'moveCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
li
|
||||
a.js-copy-card
|
||||
i.fa.fa-clipboard
|
||||
| {{_ 'copyCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-copy-checklist-cards
|
||||
i.fa.fa-copy
|
||||
| {{_ 'copyManyCardsPopup-title'}}
|
||||
unless archived
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-archive
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archive-card'}}
|
||||
if currentUser
|
||||
unless canModifyCard
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-more
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
| {{_ 'cardMorePopup-title'}}
|
||||
if canModifyCard
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-move-card-to-top
|
||||
i.fa.fa-arrow-up
|
||||
| {{_ 'moveCardToTop-title'}}
|
||||
li
|
||||
a.js-move-card-to-bottom
|
||||
i.fa.fa-arrow-down
|
||||
| {{_ 'moveCardToBottom-title'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-move-card
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'moveCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
li
|
||||
a.js-copy-card
|
||||
i.fa.fa-clipboard
|
||||
| {{_ 'copyCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-copy-checklist-cards
|
||||
i.fa.fa-copy
|
||||
| {{_ 'copyManyCardsPopup-title'}}
|
||||
unless archived
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-archive
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archive-card'}}
|
||||
a.js-move-card-to-top
|
||||
i.fa.fa-arrow-up
|
||||
| {{_ 'moveCardToTop-title'}}
|
||||
li
|
||||
a.js-move-card-to-bottom
|
||||
i.fa.fa-arrow-down
|
||||
| {{_ 'moveCardToBottom-title'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-move-card
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'moveCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
li
|
||||
a.js-copy-card
|
||||
i.fa.fa-clipboard
|
||||
| {{_ 'copyCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-copy-checklist-cards
|
||||
i.fa.fa-copy
|
||||
| {{_ 'copyManyCardsPopup-title'}}
|
||||
unless archived
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-archive
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archive-card'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-more
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
| {{_ 'cardMorePopup-title'}}
|
||||
if canModifyCard
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-more
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
| {{_ 'cardMorePopup-title'}}
|
||||
a.js-move-card-to-top
|
||||
i.fa.fa-arrow-up
|
||||
| {{_ 'moveCardToTop-title'}}
|
||||
li
|
||||
a.js-move-card-to-bottom
|
||||
i.fa.fa-arrow-down
|
||||
| {{_ 'moveCardToBottom-title'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-move-card
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'moveCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
li
|
||||
a.js-copy-card
|
||||
i.fa.fa-clipboard
|
||||
| {{_ 'copyCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-copy-checklist-cards
|
||||
i.fa.fa-copy
|
||||
| {{_ 'copyManyCardsPopup-title'}}
|
||||
unless archived
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-archive
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archive-card'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-more
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
| {{_ 'cardMorePopup-title'}}
|
||||
|
||||
template(name="exportCardPopup")
|
||||
ul.pop-over-list
|
||||
|
||||
@@ -268,7 +268,11 @@ Template.cardDetails.helpers({
|
||||
},
|
||||
|
||||
cardMaximized() {
|
||||
return !Utils.getPopupCardId() && ReactiveCache.getCurrentUser().hasCardMaximized();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const maximized = currentUser
|
||||
? currentUser.hasCardMaximized()
|
||||
: window.localStorage.getItem('cardMaximized') === 'true';
|
||||
return !Utils.getPopupCardId() && maximized;
|
||||
},
|
||||
|
||||
showActivities() {
|
||||
@@ -336,11 +340,27 @@ Template.cardDetails.helpers({
|
||||
return user && user.isVerticalScrollbars();
|
||||
},
|
||||
|
||||
currentSwimlaneListsSorted() {
|
||||
const card = Template.currentData();
|
||||
if (!card || !card.boardId) return [];
|
||||
const board = ReactiveCache.getBoard(card.boardId);
|
||||
if (!board) return [];
|
||||
const swimlaneId = card.swimlaneId;
|
||||
const selector = { boardId: card.boardId, archived: false };
|
||||
if (swimlaneId) {
|
||||
const defaultSwimlane = board.getDefaultSwimline && board.getDefaultSwimline();
|
||||
if (defaultSwimlane && defaultSwimlane._id === swimlaneId) {
|
||||
selector.swimlaneId = { $in: [swimlaneId, null, ''] };
|
||||
} else {
|
||||
selector.swimlaneId = swimlaneId;
|
||||
}
|
||||
}
|
||||
return ReactiveCache.getLists(selector, { sort: { sort: 1 } });
|
||||
},
|
||||
|
||||
isCurrentListId(listId) {
|
||||
// When called from within 'each currentBoard.lists' block, we need the card from parent context
|
||||
let data = Template.currentData();
|
||||
if (!data || typeof data.listId === 'undefined') {
|
||||
// Try parent context (for use within each loops)
|
||||
data = Template.parentData(1);
|
||||
}
|
||||
if (!data || typeof data.listId === 'undefined') return false;
|
||||
@@ -480,7 +500,11 @@ Template.cardDetails.events({
|
||||
},
|
||||
'change .js-date-format-selector'(event) {
|
||||
const dateFormat = event.target.value;
|
||||
Meteor.call('changeDateFormat', dateFormat);
|
||||
if (Meteor.userId()) {
|
||||
Meteor.call('changeDateFormat', dateFormat);
|
||||
} else {
|
||||
window.localStorage.setItem('dateFormat', dateFormat);
|
||||
}
|
||||
},
|
||||
'click .js-open-card-details-menu': Popup.open('cardDetailsActions'),
|
||||
// Mobile: switch to desktop popup view (maximize)
|
||||
@@ -614,11 +638,19 @@ Template.cardDetails.events({
|
||||
Meteor.call('toggleCustomFieldsGrid');
|
||||
},
|
||||
'click .js-maximize-card-details'() {
|
||||
Meteor.call('toggleCardMaximized');
|
||||
if (Meteor.userId()) {
|
||||
Meteor.call('toggleCardMaximized');
|
||||
} else {
|
||||
window.localStorage.setItem('cardMaximized', 'true');
|
||||
}
|
||||
autosize($('.card-details'));
|
||||
},
|
||||
'click .js-minimize-card-details'() {
|
||||
Meteor.call('toggleCardMaximized');
|
||||
if (Meteor.userId()) {
|
||||
Meteor.call('toggleCardMaximized');
|
||||
} else {
|
||||
window.localStorage.setItem('cardMaximized', 'false');
|
||||
}
|
||||
autosize($('.card-details'));
|
||||
},
|
||||
'click .js-vote'(e) {
|
||||
@@ -775,7 +807,10 @@ Template.cardDetails.helpers({
|
||||
},
|
||||
isDateFormat(format) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (!currentUser) return format === 'YYYY-MM-DD';
|
||||
if (!currentUser) {
|
||||
const stored = window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD';
|
||||
return format === stored;
|
||||
}
|
||||
return currentUser.getDateFormat() === format;
|
||||
},
|
||||
// Upload progress helpers
|
||||
@@ -1858,8 +1893,8 @@ EscapeActions.register(
|
||||
Session.set('cardDetailsIsMouseDown', false);
|
||||
|
||||
} else {
|
||||
// Prevent close card when the user is selecting text and moves the mouse cursor outside the card detail area
|
||||
Utils.goBoardId(Session.get('currentBoard'));
|
||||
// Trigger the close button so all close logic (openCards, routing, mini-screen) runs consistently
|
||||
$('.js-close-card-details').first().trigger('click');
|
||||
}
|
||||
},
|
||||
() => {
|
||||
|
||||
@@ -34,7 +34,7 @@ template(name="checklists")
|
||||
template(name="checklistDetail")
|
||||
.js-checklist.checklist.nodragscroll
|
||||
+inlinedForm(classNames="js-edit-checklist-title" checklist = checklist)
|
||||
+editChecklistItemForm(checklist = checklist)
|
||||
+editChecklistForm(checklist = checklist)
|
||||
else
|
||||
.checklist-title
|
||||
span
|
||||
@@ -85,6 +85,19 @@ template(name="addChecklistItemForm")
|
||||
label.toggle-label(for="toggleNewlineBecomesNewChecklistItemOriginOrder")
|
||||
| {{_ 'originOrder'}}
|
||||
|
||||
template(name="editChecklistForm")
|
||||
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
textarea.js-edit-checklist-item(rows='1' autofocus dir="auto")
|
||||
if $eq type 'item'
|
||||
= item.title
|
||||
else
|
||||
= checklist.title
|
||||
.edit-controls.clearfix
|
||||
button.primary.confirm.js-submit-edit-checklist-item-form(type="submit") {{_ 'save'}}
|
||||
a.fa.fa-times-thin.js-close-inlined-form(title="{{_ 'close-edit-checklist-item'}}")
|
||||
span(title=createdAt) {{ displayDate createdAt }}
|
||||
|
||||
template(name="editChecklistItemForm")
|
||||
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
@@ -171,6 +184,15 @@ template(name="checklistActionsPopup")
|
||||
else
|
||||
input.toggle-switch(type="checkbox" id="toggleHideAllChecklistItems_{{checklist._id}}")
|
||||
label.toggle-label(for="toggleHideAllChecklistItems_{{checklist._id}}")
|
||||
a.js-show-checklist-at-minicard
|
||||
i.fa.fa-th-list
|
||||
| {{_ "show-on-minicard"}} ...
|
||||
.material-toggle-switch(title="{{_ 'show-on-minicard'}}")
|
||||
if checklist.showChecklistAtMinicard
|
||||
input.toggle-switch(type="checkbox" id="toggleShowChecklistAtMinicard_{{checklist._id}}" checked="checked")
|
||||
else
|
||||
input.toggle-switch(type="checkbox" id="toggleShowChecklistAtMinicard_{{checklist._id}}")
|
||||
label.toggle-label(for="toggleShowChecklistAtMinicard_{{checklist._id}}")
|
||||
|
||||
template(name="copyChecklistPopup")
|
||||
+copyAndMoveChecklist
|
||||
|
||||
@@ -135,8 +135,11 @@ Template.checklists.events({
|
||||
event.preventDefault();
|
||||
const textarea = tpl.find('textarea.js-edit-checklist-item');
|
||||
const title = textarea.value.trim();
|
||||
const checklist = Template.currentData().checklist;
|
||||
checklist.setTitle(title);
|
||||
const formData = Blaze.getData(event.currentTarget) || Blaze.getData(event.target);
|
||||
const checklist = formData?.checklist;
|
||||
if (checklist) {
|
||||
checklist.setTitle(title);
|
||||
}
|
||||
},
|
||||
'submit .js-add-checklist-item'(event, tpl) {
|
||||
event.preventDefault();
|
||||
@@ -271,6 +274,11 @@ Template.checklistActionsPopup.events({
|
||||
Template.currentData().checklist.toggleHideAllChecklistItems();
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-show-checklist-at-minicard'(event) {
|
||||
event.preventDefault();
|
||||
Template.currentData().checklist.toggleShowChecklistAtMinicard();
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.editChecklistItemForm.onRendered(function () {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.2vh;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.minicard-wrapper.placeholder {
|
||||
background: #ccc;
|
||||
@@ -16,8 +16,8 @@
|
||||
}
|
||||
.minicard-wrapper.ui-sortable-helper .and-n-other {
|
||||
width: 100%;
|
||||
height: 2.5vh;
|
||||
padding: 0.5vh;
|
||||
height: 25px;
|
||||
padding: 5px;
|
||||
background-color: #f2f2f2;
|
||||
text-align: center;
|
||||
border-radius: 0.4vw;
|
||||
@@ -30,13 +30,13 @@
|
||||
}
|
||||
@media only screen {
|
||||
.minicard {
|
||||
padding: 0.8vh 1vw 0.3vh;
|
||||
padding: 8px 1vw 3px;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
background-color: #fff;
|
||||
min-height: 2.5vh;
|
||||
box-shadow: 0 0.2vh 0.3vh rgba(0,0,0,0.15);
|
||||
min-height: 25px;
|
||||
box-shadow: 0 2px 3px rgba(0,0,0,0.15);
|
||||
border-radius: 0.3vw;
|
||||
color: #4d4d4d;
|
||||
overflow: hidden;
|
||||
@@ -77,7 +77,7 @@
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
z-index: 25;
|
||||
box-shadow: -0.3vw 0.2vh 0.3vh rgba(0,0,0,0.2);
|
||||
box-shadow: -0.3vw 2px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
.minicard:hover:not(.minicard-composer),
|
||||
.is-selected .minicard,
|
||||
@@ -91,9 +91,9 @@
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
height: 20vh;
|
||||
height: 150px;
|
||||
user-select: none;
|
||||
margin: 0.8vh -1vw 0.8vh -1vw;
|
||||
margin: 8px -1vw 8px -1vw;
|
||||
border-radius: top 0.3vw;
|
||||
}
|
||||
.minicard .minicard-labels {
|
||||
@@ -105,7 +105,7 @@
|
||||
height: clamp(12px, 1.5vw, 16px);
|
||||
border-radius: 0.3vw;
|
||||
margin-right: 0.4vw;
|
||||
margin-bottom: 0.4vh;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.minicard .minicard-labels-no-text {
|
||||
display: flex;
|
||||
@@ -137,7 +137,7 @@
|
||||
height: clamp(20px, 2.5vw, 28px);
|
||||
position: absolute;
|
||||
right: 0vw;
|
||||
top: 4vh;
|
||||
top: 28px;
|
||||
display: none;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -313,7 +313,7 @@
|
||||
|
||||
.minicard .badges {
|
||||
float: left;
|
||||
margin-top: 1vh;
|
||||
margin-top: 8px;
|
||||
color: #808080;
|
||||
}
|
||||
.minicard .badges:empty {
|
||||
@@ -322,7 +322,7 @@
|
||||
.minicard .badges .badge {
|
||||
float: left;
|
||||
margin-right: 1.5vw;
|
||||
margin-bottom: 0.4vh;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.minicard .badges .badge.is-finished {
|
||||
@@ -356,7 +356,7 @@
|
||||
.minicard .minicard-creator {
|
||||
float: right;
|
||||
margin-left: 0.7vw;
|
||||
margin-bottom: 0.5vh;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.minicard .minicard-members .member,
|
||||
.minicard .minicard-assignees .member,
|
||||
@@ -365,7 +365,7 @@
|
||||
border-radius: 50%;
|
||||
height: clamp(24px, 3.5vw, 32px);
|
||||
width: clamp(24px, 3.5vw, 32px);
|
||||
margin-bottom: 0.5vh;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.minicard .minicard-members .assignee,
|
||||
.minicard .minicard-assignees .assignee,
|
||||
@@ -378,7 +378,7 @@
|
||||
.minicard .minicard-members + .badges,
|
||||
.minicard .minicard-assignees + .badges,
|
||||
.minicard .minicard-creator + .badges {
|
||||
margin-top: 0.7vh;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.minicard .minicard-assignees {
|
||||
border-bottom: 1px solid #f00;
|
||||
@@ -391,11 +391,11 @@
|
||||
display: none;
|
||||
}
|
||||
.minicard .minicard-description {
|
||||
padding: 0.8vh 0 0 1vw;
|
||||
padding: 8px 0 0 1vw;
|
||||
color: #000;
|
||||
background-color: #eee;
|
||||
width: 100%;
|
||||
margin-bottom: 0.3vh;
|
||||
margin-bottom: 3px;
|
||||
margin-left: -0.5vw;
|
||||
border-radius: 0.4vw;
|
||||
overflow: hidden;
|
||||
@@ -405,7 +405,7 @@
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.minicard.minicard-composer {
|
||||
margin-bottom: 1.3vh;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.minicard.minicard-composer textarea.minicard-composer-textarea,
|
||||
.minicard.minicard-composer textarea.minicard-composer-textarea:focus {
|
||||
@@ -416,9 +416,9 @@
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 22vh;
|
||||
min-height: 5vh;
|
||||
margin-bottom: 2.5vh;
|
||||
max-height: 200px;
|
||||
min-height: 50px;
|
||||
margin-bottom: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.parent-prefix {
|
||||
@@ -736,7 +736,7 @@
|
||||
.minicard-list-name {
|
||||
font-size: 0.75em;
|
||||
color: #8c8c8c;
|
||||
margin-top: 0.2vh;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3vw;
|
||||
@@ -745,9 +745,11 @@
|
||||
/* Checklist display on minicard */
|
||||
.minicard-checklist {
|
||||
width: 100%;
|
||||
margin-top: 0.5vh;
|
||||
margin-bottom: 0.5vh;
|
||||
padding: 0.3vh 0.5vw;
|
||||
box-sizing: border-box;
|
||||
clear: both;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 5px;
|
||||
padding: 3px 0.5vw;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 0.3vw;
|
||||
border: 1px solid #e0e0e0;
|
||||
@@ -757,7 +759,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.3vh;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.minicard-checklist .checklist-title {
|
||||
@@ -765,6 +767,7 @@
|
||||
font-weight: bold;
|
||||
color: #4d4d4d;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.minicard-checklist .checklist-menu {
|
||||
@@ -783,13 +786,13 @@
|
||||
.minicard-checklist .checklist-item {
|
||||
font-size: 0.75em;
|
||||
color: #666;
|
||||
margin-bottom: 0.2vh;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3vw;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
padding: 0.2vh 0;
|
||||
padding: 2px 0;
|
||||
border-radius: 0.2vw;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
@@ -806,7 +809,7 @@
|
||||
.minicard-checklist .checklist-item .check-box-unicode {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8em;
|
||||
margin-top: 0.1vh;
|
||||
margin-top: 1px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ template(name="minicard")
|
||||
+viewer
|
||||
if showCardNumber
|
||||
span.card-number
|
||||
| ##{getCardNumber}
|
||||
| ##{getCardNumber}
|
||||
= getTitle
|
||||
if showLabels
|
||||
if labels
|
||||
@@ -148,14 +148,16 @@ template(name="minicard")
|
||||
.minicard-creator
|
||||
+userAvatar(userId=this.userId noRemove=true)
|
||||
|
||||
if shouldShowChecklistAtMinicard
|
||||
each shouldShowChecklistAtMinicard
|
||||
+minicardChecklist(checklist=. card=..)
|
||||
.badges
|
||||
if canModifyCard
|
||||
if comments.length
|
||||
.badge(title="{{_ 'card-comments-title' comments.length }}")
|
||||
span.badge-icon.badge-comment.badge-text
|
||||
i.fa.fa-comment-o
|
||||
= ' '
|
||||
= comments.length
|
||||
if comments.length
|
||||
.badge(title="{{_ 'card-comments-title' comments.length }}")
|
||||
span.badge-icon.badge-comment.badge-text
|
||||
i.fa.fa-comment-o
|
||||
= ' '
|
||||
= comments.length
|
||||
//span.badge-comment.badge-text
|
||||
//|
|
||||
{{_ 'comment'}}
|
||||
@@ -197,9 +199,6 @@ template(name="minicard")
|
||||
span.badge-icon
|
||||
i.fa.fa-sort-numeric-asc
|
||||
span.badge-text.check-list-sort {{ sort }}
|
||||
if shouldShowChecklistAtMinicard
|
||||
each shouldShowChecklistAtMinicard
|
||||
+minicardChecklist(checklist=. card=..)
|
||||
if currentBoard.allowsDescriptionTextOnMinicard
|
||||
if getDescription
|
||||
.minicard-description
|
||||
@@ -224,10 +223,26 @@ template(name="editCardSortOrderPopup")
|
||||
template(name="minicardChecklist")
|
||||
.minicard-checklist
|
||||
.checklist-header
|
||||
.checklist-title= checklist.title
|
||||
if canModifyCard
|
||||
+inlinedForm(classNames="js-edit-checklist-title" checklist=checklist)
|
||||
+editChecklistForm(checklist=checklist)
|
||||
else
|
||||
h4.checklist-title.js-open-inlined-form.is-editable= checklist.title
|
||||
else
|
||||
h4.checklist-title= checklist.title
|
||||
if canModifyCard
|
||||
a.checklist-menu.js-open-checklist-menu(title="{{_ 'checklistActionsPopup-title'}}")
|
||||
i.fa.fa-bars
|
||||
each visibleItems
|
||||
+checklistItemDetail(item = . checklist = checklist card = card)
|
||||
.checklist-items.js-checklist-items
|
||||
each visibleItems
|
||||
+inlinedForm(classNames="js-edit-checklist-item" item=. checklist=checklist)
|
||||
+editChecklistItemForm(type='item' item=. checklist=checklist)
|
||||
else
|
||||
+checklistItemDetail(item=. checklist=checklist card=card)
|
||||
if canModifyCard
|
||||
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist=checklist)
|
||||
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=false)
|
||||
else
|
||||
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
@@ -4,6 +4,18 @@ import { CustomFieldStringTemplate } from '/client/lib/customFields';
|
||||
import { handleFileUpload } from './attachments';
|
||||
import uploadProgressManager from '../../lib/uploadProgressManager';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import ChecklistItems from '/models/checklistItems';
|
||||
|
||||
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
|
||||
if (!board) return false;
|
||||
if (board[onMinicardField] !== null && board[onMinicardField] !== undefined) {
|
||||
return board[onMinicardField];
|
||||
}
|
||||
if (legacyField && board[legacyField] !== null && board[legacyField] !== undefined) {
|
||||
return board[legacyField];
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Template.cards.events({
|
||||
// 'click .member': Popup.open('cardMember')
|
||||
@@ -51,48 +63,40 @@ Template.minicard.helpers({
|
||||
|
||||
showMembers() {
|
||||
const board = this.board();
|
||||
let ret = false;
|
||||
if (board) {
|
||||
ret = board.allowsMembersOnMinicard ?? false;
|
||||
}
|
||||
return ret;
|
||||
return getMinicardFlag(board, 'allowsMembersOnMinicard', 'allowsMembers', true);
|
||||
},
|
||||
|
||||
showAssignee() {
|
||||
const board = this.board();
|
||||
let ret = false;
|
||||
if (board) {
|
||||
ret = board.allowsAssigneeOnMinicard ?? false;
|
||||
}
|
||||
return ret;
|
||||
return getMinicardFlag(board, 'allowsAssigneeOnMinicard', 'allowsAssignee', true);
|
||||
},
|
||||
showReceived() {
|
||||
const board = this.board();
|
||||
return board ? board.allowsReceivedDateOnMinicard : false;
|
||||
return getMinicardFlag(board, 'allowsReceivedDateOnMinicard', 'allowsReceivedDate', true);
|
||||
},
|
||||
showStart() {
|
||||
const board = this.board();
|
||||
return board ? board.allowsStartDateOnMinicard : false;
|
||||
return getMinicardFlag(board, 'allowsStartDateOnMinicard', 'allowsStartDate', true);
|
||||
},
|
||||
showDue() {
|
||||
const board = this.board();
|
||||
return board ? board.allowsDueDateOnMinicard : false;
|
||||
return getMinicardFlag(board, 'allowsDueDateOnMinicard', 'allowsDueDate', true);
|
||||
},
|
||||
showEnd() {
|
||||
const board = this.board();
|
||||
return board ? board.allowsEndDateOnMinicard : false;
|
||||
return getMinicardFlag(board, 'allowsEndDateOnMinicard', 'allowsEndDate', true);
|
||||
},
|
||||
showLabels() {
|
||||
const board = this.board();
|
||||
return board ? board.allowsLabelsOnMinicard : false;
|
||||
return getMinicardFlag(board, 'allowsLabelsOnMinicard', 'allowsLabels', true);
|
||||
},
|
||||
showCardNumber() {
|
||||
const board = this.board();
|
||||
return board ? board.allowsCardNumberOnMinicard : false;
|
||||
return getMinicardFlag(board, 'allowsCardNumberOnMinicard', 'allowsCardNumber', false);
|
||||
},
|
||||
showSubtasks() {
|
||||
const board = this.board();
|
||||
return board ? board.allowsSubtasksOnMinicard : false;
|
||||
return getMinicardFlag(board, 'allowsSubtasksOnMinicard', 'allowsSubtasks', true);
|
||||
},
|
||||
|
||||
hiddenMinicardLabelText() {
|
||||
@@ -109,10 +113,11 @@ Template.minicard.helpers({
|
||||
if (!this.coverId) return null;
|
||||
const attachment = ReactiveCache.getAttachment(this.coverId);
|
||||
if (!attachment) return null;
|
||||
const coverId = this.coverId;
|
||||
const coverLink = typeof attachment.link === 'function' ? attachment.link() : '';
|
||||
if (!coverLink) return null;
|
||||
return {
|
||||
link() {
|
||||
return `/cdn/storage/attachments/${coverId}`;
|
||||
return coverLink;
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -270,6 +275,13 @@ Template.minicardChecklist.helpers({
|
||||
});
|
||||
|
||||
Template.minicardChecklist.events({
|
||||
'click .js-convert-checklist-item-to-card'(event) {
|
||||
event.stopPropagation();
|
||||
const formData = Blaze.getData(event.currentTarget);
|
||||
if (!formData) return;
|
||||
const context = { currentData: () => formData };
|
||||
Popup.open('convertChecklistItemToCard').call(context, event);
|
||||
},
|
||||
'click .js-open-checklist-menu'(event) {
|
||||
const data = Template.currentData();
|
||||
const checklist = data.checklist || data;
|
||||
@@ -277,6 +289,66 @@ Template.minicardChecklist.events({
|
||||
const context = { currentData: () => ({ checklist, card }) };
|
||||
Popup.open('checklistActions').call(context, event);
|
||||
},
|
||||
'click .js-checklist-item .check-box-container'(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const data = Blaze.getData(event.target) || Blaze.getData(event.currentTarget);
|
||||
const item = data && data.item;
|
||||
if (item && item._id) {
|
||||
item.toggleItem();
|
||||
}
|
||||
},
|
||||
'click .js-submit-edit-checklist-item-form'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const $btn = $(event.currentTarget);
|
||||
const $form = $btn.closest('form');
|
||||
const textarea = $form.find('textarea.js-edit-checklist-item')[0];
|
||||
if (!textarea) return;
|
||||
const title = textarea.value.trim();
|
||||
if (!title) return;
|
||||
const formData = Blaze.getData($form[0]);
|
||||
if (formData && formData.item && formData.item._id) {
|
||||
formData.item.setTitle(title);
|
||||
} else if (formData && formData.checklist && formData.checklist._id) {
|
||||
formData.checklist.setTitle(title);
|
||||
}
|
||||
$form.find('.js-close-inlined-form').trigger('click');
|
||||
},
|
||||
'submit .js-add-checklist-item'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const textarea = tpl.find('textarea.js-add-checklist-item');
|
||||
if (!textarea) return;
|
||||
const title = textarea.value.trim();
|
||||
const data = Template.currentData() || {};
|
||||
const checklist = data.checklist;
|
||||
if (!checklist || !title) return;
|
||||
const items = checklist.items();
|
||||
const lastItem = items[items.length - 1] || null;
|
||||
const sortIndex = Utils.calculateIndexData(lastItem, null).base;
|
||||
ChecklistItems.insert({
|
||||
title,
|
||||
checklistId: checklist._id,
|
||||
cardId: checklist.cardId,
|
||||
sort: sortIndex,
|
||||
});
|
||||
textarea.value = '';
|
||||
textarea.focus();
|
||||
},
|
||||
'click .js-delete-checklist-item': Popup.afterConfirm('checklistItemDelete', function () {
|
||||
Popup.back();
|
||||
const item = this && this.item ? this.item : this;
|
||||
if (item && item._id) {
|
||||
ChecklistItems.remove(item._id);
|
||||
}
|
||||
}),
|
||||
'keydown textarea.js-add-checklist-item'(event) {
|
||||
if (event.keyCode === 13 && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const $form = $(event.currentTarget).closest('form');
|
||||
$form.find('button[type=submit]').click();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Template.editCardSortOrderPopup.events({
|
||||
|
||||
@@ -152,12 +152,12 @@ body.list-resizing-active * {
|
||||
/* Ensure consistent header height for all lists */
|
||||
.list-header {
|
||||
/* Maintain consistent height and padding for all lists */
|
||||
min-height: 2.5vh !important;
|
||||
min-height: 30px !important;
|
||||
height: auto !important;
|
||||
padding: 2.5vh 1.5vw 0.5vh !important;
|
||||
padding: 28px 1.5vw 6px !important;
|
||||
/* Make sure the background covers the full height */
|
||||
background-color: #e4e4e4 !important;
|
||||
border-bottom: 0.8vh solid #e4e4e4 !important;
|
||||
border-bottom: 8px solid #e4e4e4 !important;
|
||||
/* Use original display for consistent button positioning */
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
@@ -401,24 +401,24 @@ form.inlined-form .list-composer {
|
||||
.list.list-composer .list-name-input,
|
||||
.list .list-composer .list-name-input {
|
||||
background: #fff;
|
||||
margin: -0.4vh 0 1vh;
|
||||
margin: -4px 0 8px;
|
||||
}
|
||||
.list-header-add {
|
||||
flex: 0 0 auto;
|
||||
padding: 1.5vh 1.5vw;
|
||||
padding: 14px 1.5vw;
|
||||
position: relative;
|
||||
min-height: 2.5vh;
|
||||
min-height: 30px;
|
||||
}
|
||||
.list-header {
|
||||
flex: 0 0 auto;
|
||||
padding: 2.5vh 1.5vw 0.5vh;
|
||||
padding: 28px 1.5vw 6px;
|
||||
position: relative;
|
||||
min-height: 2.5vh;
|
||||
min-height: 30px;
|
||||
background-color: #e4e4e4;
|
||||
border-bottom: 0.8vh solid #e4e4e4;
|
||||
border-bottom: 8px solid #e4e4e4;
|
||||
}
|
||||
.list-header.list-header-card-count {
|
||||
min-height: 4.5vh;
|
||||
min-height: 45px;
|
||||
height: auto;
|
||||
}
|
||||
.list-header.ui-sortable-handle {
|
||||
@@ -433,7 +433,7 @@ form.inlined-form .list-composer {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
min-height: 1.2vh;
|
||||
min-height: 14px;
|
||||
min-width: 4vw;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
@@ -1196,7 +1196,7 @@ form.inlined-form .list-composer {
|
||||
|
||||
/* Allow long list titles to expand on desktop (non-mobile, non-collapsed) */
|
||||
.list:not(.mobile-view):not(.list-collapsed) .list-header {
|
||||
padding-top: calc(2.5vh - 6px) !important;
|
||||
padding-top: 22px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -345,9 +345,19 @@ Template.list.onCreated(function () {
|
||||
return 550;
|
||||
};
|
||||
|
||||
// Read the horizontal page coordinate from either a jQuery mouse event
|
||||
// or a native touch event (touchstart/move expose `touches`, touchend
|
||||
// exposes `changedTouches`).
|
||||
const getEventPageX = (e) => {
|
||||
const oe = e.originalEvent || e;
|
||||
if (oe.touches && oe.touches.length) return oe.touches[0].pageX;
|
||||
if (oe.changedTouches && oe.changedTouches.length) return oe.changedTouches[0].pageX;
|
||||
return e.pageX;
|
||||
};
|
||||
|
||||
const startResize = (e) => {
|
||||
isResizing = true;
|
||||
startX = e.pageX || e.originalEvent.touches[0].pageX;
|
||||
startX = getEventPageX(e);
|
||||
startWidth = $list.outerWidth();
|
||||
|
||||
// Add visual feedback
|
||||
@@ -366,7 +376,7 @@ Template.list.onCreated(function () {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
|
||||
const currentX = getEventPageX(e);
|
||||
const deltaX = currentX - startX;
|
||||
const newWidth = Math.max(minWidth, startWidth + deltaX);
|
||||
|
||||
@@ -390,7 +400,7 @@ Template.list.onCreated(function () {
|
||||
isResizing = false;
|
||||
|
||||
// Calculate final width
|
||||
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
|
||||
const currentX = getEventPageX(e);
|
||||
const deltaX = currentX - startX;
|
||||
const finalWidth = Math.max(minWidth, startWidth + deltaX);
|
||||
const listConstraint = getListConstraint();
|
||||
@@ -468,10 +478,15 @@ Template.list.onCreated(function () {
|
||||
$(document).on('mousemove', doResize);
|
||||
$(document).on('mouseup', stopResize);
|
||||
|
||||
// Touch events for mobile
|
||||
$resizeHandle.on('touchstart', startResize, { passive: false });
|
||||
$(document).on('touchmove', doResize, { passive: false });
|
||||
$(document).on('touchend', stopResize, { passive: false });
|
||||
// Touch events for mobile.
|
||||
// NOTE: jQuery's .on() does not accept an addEventListener-style options
|
||||
// object — passing { passive: false } made jQuery bind the object itself
|
||||
// as the handler, throwing "handler.apply is not a function" on every
|
||||
// touch. Use the native API so { passive: false } actually applies and
|
||||
// preventDefault() works during the resize.
|
||||
$resizeHandle[0].addEventListener('touchstart', startResize, { passive: false });
|
||||
document.addEventListener('touchmove', doResize, { passive: false });
|
||||
document.addEventListener('touchend', stopResize, { passive: false });
|
||||
|
||||
// Prevent dragscroll interference
|
||||
$resizeHandle.on('mousedown', (e) => {
|
||||
@@ -492,8 +507,8 @@ Template.list.onCreated(function () {
|
||||
tpl.view.onViewDestroyed(() => {
|
||||
$(document).off('mousemove', doResize);
|
||||
$(document).off('mouseup', stopResize);
|
||||
$(document).off('touchmove', doResize);
|
||||
$(document).off('touchend', stopResize);
|
||||
document.removeEventListener('touchmove', doResize);
|
||||
document.removeEventListener('touchend', stopResize);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
template(name="listBody")
|
||||
unless collapsed
|
||||
.list-body(class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}")
|
||||
.minicards.clearfix.js-minicards(class="{{#if reachedWipLimit}}js-list-full{{/if}}")
|
||||
+inlinedForm(autoclose=false position="top")
|
||||
+addCardForm(listId=_id position="top")
|
||||
ul.sidebar-list
|
||||
each customFieldsSum
|
||||
li
|
||||
.list-body(class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}")
|
||||
.minicards.clearfix.js-minicards(class="{{#if reachedWipLimit}}js-list-full{{/if}}")
|
||||
+inlinedForm(autoclose=false position="top")
|
||||
+addCardForm(listId=_id position="top")
|
||||
ul.sidebar-list
|
||||
each customFieldsSum
|
||||
li
|
||||
+viewer
|
||||
= name
|
||||
if $eq customFieldsSum.type "number"
|
||||
+viewer
|
||||
= name
|
||||
if $eq customFieldsSum.type "number"
|
||||
+viewer
|
||||
= value
|
||||
if $eq customFieldsSum.type "currency"
|
||||
+viewer
|
||||
= formattedCurrencyCustomFieldValue(value)
|
||||
each (cardsWithLimit (idOrNull ../../_id))
|
||||
a.minicard-wrapper.js-minicard(href=originRelativeUrl
|
||||
class="{{#if cardIsSelected}}is-selected{{/if}}"
|
||||
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
if MultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-multi-selection(
|
||||
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
+minicard(this)
|
||||
if (showSpinner (idOrNull ../../_id))
|
||||
+spinnerList
|
||||
if canSeeAddCard
|
||||
+inlinedForm(autoclose=false position="bottom")
|
||||
+addCardForm(listId=_id position="bottom")
|
||||
else
|
||||
a.open-minicard-composer.js-card-composer.js-open-inlined-form(title="{{_ 'add-card-to-bottom-of-list'}}")
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-card'}}
|
||||
= value
|
||||
if $eq customFieldsSum.type "currency"
|
||||
+viewer
|
||||
= formattedCurrencyCustomFieldValue(value)
|
||||
each (cardsWithLimit (idOrNull ../../_id))
|
||||
a.minicard-wrapper.js-minicard(href=originRelativeUrl
|
||||
class="{{#if cardIsSelected}}is-selected{{/if}}"
|
||||
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
if MultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-multi-selection(
|
||||
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
+minicard(this)
|
||||
if (showSpinner (idOrNull ../../_id))
|
||||
+spinnerList
|
||||
if canSeeAddCard
|
||||
+inlinedForm(autoclose=false position="bottom")
|
||||
+addCardForm(listId=_id position="bottom")
|
||||
else
|
||||
a.open-minicard-composer.js-card-composer.js-open-inlined-form(title="{{_ 'add-card-to-bottom-of-list'}}")
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-card'}}
|
||||
+inlinedForm(autoclose=false position="bottom")
|
||||
+addCardForm(listId=_id position="bottom")
|
||||
|
||||
template(name="spinnerList")
|
||||
.sk-spinner.sk-spinner-list(
|
||||
|
||||
@@ -192,6 +192,13 @@ Template.listBody.onCreated(function () {
|
||||
}
|
||||
const clickedTitle = $target.closest('.minicard-title').length > 0;
|
||||
const clickedLinkedReference = $target.closest('.js-linked-link').length > 0;
|
||||
const clickedChecklist = $target.closest('.minicard-checklist').length > 0;
|
||||
|
||||
if (clickedChecklist) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Title clicks should open the regular board card details view.
|
||||
if (clickedTitle && !clickedLinkedReference) {
|
||||
|
||||
@@ -177,24 +177,25 @@
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list {
|
||||
transition: opacity 0.2s;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 10px;
|
||||
margin: -10px;
|
||||
margin: 10px;
|
||||
flex: 1; /* Take up available space */
|
||||
min-width: 0; /* Allow shrinking below content size */
|
||||
display: flex; /* Use flexbox for better control */
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
scrollbar-color: white #2573a7;
|
||||
}
|
||||
|
||||
/* Hide scrollbar completely */
|
||||
#header-quick-access ul.header-quick-access-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#header-quick-access ul.header-quick-access-list {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: thin; /* IE and Edge */
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list li {
|
||||
display: inline-block; /* Keep inline-block for proper spacing */
|
||||
|
||||
@@ -68,12 +68,8 @@ template(name="header")
|
||||
+viewer
|
||||
= title
|
||||
else
|
||||
li.current.empty(title="{{_ 'quick-access-description'}}")
|
||||
li.current(title="{{_ 'quick-access-description'}}")
|
||||
| {{_ 'quick-access-description'}}
|
||||
#header-new-board-icon
|
||||
// Next line is used only for spacing at header,
|
||||
// there is no visible clickable icon.
|
||||
#header-new-board-icon
|
||||
//
|
||||
Hide duplicate create board button,
|
||||
//
|
||||
|
||||
@@ -78,23 +78,32 @@
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Allow dynamic max-height to override default constraint */
|
||||
/* Bounded height: flex column + clip so inline max-height cannot spill past the shell.
|
||||
Class pop-over--bounded matches popup.tpl.jade when max-height is set (more reliable
|
||||
than attribute substring matching on the style attribute alone). */
|
||||
.pop-over.pop-over--bounded,
|
||||
.pop-over[style*='max-height'] {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pop-over.pop-over--bounded .header,
|
||||
.pop-over[style*='max-height'] .header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pop-over.pop-over--bounded .content-wrapper,
|
||||
.pop-over[style*='max-height'] .content-wrapper {
|
||||
flex: 1 1 auto;
|
||||
/* 0% basis: use remaining space under header, scroll inside (not past white bg) */
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: inherit;
|
||||
}
|
||||
.pop-over .content-container {
|
||||
width: 100%;
|
||||
@@ -103,6 +112,7 @@
|
||||
}
|
||||
|
||||
/* Allow dynamic max-height to override default constraint for content-container */
|
||||
.pop-over.pop-over--bounded .content-container,
|
||||
.pop-over[style*='max-height'] .content-container {
|
||||
max-height: none;
|
||||
}
|
||||
@@ -757,3 +767,60 @@ body.mobile-mode .pop-over .content-wrapper {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FIX: Popup overflow (Issues #6200, #6221, #6223)
|
||||
Prevents menus from overflowing outside viewport
|
||||
============================================ */
|
||||
|
||||
/* Specific fix for list actions menu */
|
||||
.pop-over[data-popup="listActionsPopup"] {
|
||||
max-height: 85vh !important;
|
||||
}
|
||||
|
||||
.pop-over[data-popup="listActionsPopup"] .content-wrapper {
|
||||
max-height: calc(85vh - 50px) !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Prevent any popup from overflowing viewport */
|
||||
.pop-over {
|
||||
max-height: 90vh !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.pop-over .content-wrapper {
|
||||
flex: 1 1 auto !important;
|
||||
overflow-y: auto !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.pop-over .content-container {
|
||||
flex: 1 1 auto !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.pop-over .content {
|
||||
flex: 1 1 auto !important;
|
||||
overflow-y: auto !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
/* Keep header fixed while content scrolls */
|
||||
.pop-over .header {
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* Mobile-specific adjustments */
|
||||
@media screen and (max-width: 800px) {
|
||||
.pop-over[data-popup="listActionsPopup"] {
|
||||
max-height: 100vh !important;
|
||||
}
|
||||
|
||||
.pop-over[data-popup="listActionsPopup"] .content-wrapper {
|
||||
max-height: calc(100vh - 48px) !important;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
class="{{#unless title}}miniprofile{{/unless}}"
|
||||
class=currentBoard.colorClass
|
||||
class="{{#unless title}}no-title{{/unless}}"
|
||||
class="{{#if offset.maxHeight}}pop-over--bounded{{/if}}"
|
||||
data-popup="{{popupName}}"
|
||||
style="left:{{offset.left}}px; top:{{offset.top}}px;{{#if offset.maxHeight}} max-height:{{offset.maxHeight}}px;{{/if}}")
|
||||
.header
|
||||
|
||||
@@ -2,7 +2,205 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.attachments-content .move-compact-section,
|
||||
.setting-content .content-body .main-body .form-group .compact-block {
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
background: #fff8e1;
|
||||
max-width: 650px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.attachments-content .move-compact-section p,
|
||||
.setting-content .content-body .main-body .form-group .compact-block p {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.attachments-content .move-compact-section h4,
|
||||
.setting-content .content-body .main-body h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachments-content .compact-result-list,
|
||||
.setting-content .content-body .main-body .compact-result-list {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 20px;
|
||||
font-size: 0.85em;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-section {
|
||||
margin: 12px 0 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-counter {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-filename {
|
||||
font-size: 0.85em;
|
||||
color: #57606a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-bar-track {
|
||||
background: #dde1e4;
|
||||
border-radius: 4px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-bar-fill {
|
||||
background: #1976d2;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s ease;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-bar-indeterminate {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, transparent 0%, #1976d2 40%, #42a5f5 60%, transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: move-progress-slide 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes move-progress-slide {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.attachments-content .move-progress-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.attachments-content .move-storage-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.attachments-content .move-storage-selects {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.attachments-content .move-storage-form .form-group {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attachments-content .move-storage-form .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachments-content .move-attachment-buttons button,
|
||||
.attachments-content .board-attachments td button {
|
||||
background-color: #1976d2;
|
||||
border: 1px solid #1565c0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.attachments-content .move-attachment-buttons button:hover,
|
||||
.attachments-content .board-attachments td button:hover {
|
||||
background-color: #1565c0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.attachments-content hr {
|
||||
height: 0px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 12px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row input[type="checkbox"] {
|
||||
position: static;
|
||||
left: auto;
|
||||
visibility: visible;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row input[type="checkbox"]:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row label {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .storage-rw-row input[type="checkbox"]:disabled + label {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .gridfs-stats-table {
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .gridfs-stats-table th,
|
||||
.setting-content .content-body .main-body .gridfs-stats-table td {
|
||||
border: 1px solid #d0d7de;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
@@ -3,28 +3,320 @@ template(name="attachments")
|
||||
unless currentUser.isAdmin
|
||||
| {{_ 'error-notAuthorized'}}
|
||||
else
|
||||
.content-title.ext-box
|
||||
span
|
||||
span.emoji-icon
|
||||
i.fa.fa-paperclip
|
||||
| {{_ 'attachments'}}
|
||||
.content-body
|
||||
.side-menu
|
||||
ul
|
||||
li
|
||||
a.js-move-attachments(data-id="move-attachments")
|
||||
li(class="{{#if isMoveActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="move")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move'}}
|
||||
li(class="{{#if isLimitsActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="limits")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sliders
|
||||
| {{_ 'attachment-limits'}}
|
||||
li(class="{{#if isGridFsActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="gridfs")
|
||||
span.emoji-icon
|
||||
i.fa.fa-database
|
||||
| {{_ 'mongodb-gridfs-storage'}}
|
||||
li(class="{{#if isFilesystemActive}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="filesystem")
|
||||
span.emoji-icon
|
||||
i.fa.fa-folder-open
|
||||
| {{_ 'filesystem-storage'}}
|
||||
li(class="{{#if isS3Active}}active{{/if}}")
|
||||
a.js-attachments-menu(data-id="s3")
|
||||
span.emoji-icon
|
||||
i.fa.fa-cloud
|
||||
| {{_ 's3-minio-storage'}}
|
||||
|
||||
.main-body
|
||||
if loading.get
|
||||
+spinner
|
||||
else if showMoveAttachments.get
|
||||
+moveAttachments
|
||||
else if isMoveActive
|
||||
ul#attachment-move-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'attachment-move'}}
|
||||
+moveAttachments
|
||||
else if isLimitsActive
|
||||
ul#attachment-limits-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'attachment-transfer-limits-title'}}
|
||||
.description {{_ 'attachment-transfer-limits-description'}}
|
||||
|
||||
li
|
||||
.title {{_ 'attachment-upload-limit-label'}}
|
||||
.form-group
|
||||
select.wekan-form-control.js-attachment-limit-mode(data-field="attachmentsUploadMaxBytes")
|
||||
each attachmentTransferLimitModeOptions 'attachmentsUploadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
.form-group(class="{{#if isAttachmentLimitMode 'attachmentsUploadMaxBytes' 'max-size'}}{{else}}hide{{/if}}")
|
||||
input.wekan-form-control#attachments-upload-limit-value(type="number" min="1" step="any" value="{{attachmentTransferLimitValue 'attachmentsUploadMaxBytes'}}")
|
||||
select.wekan-form-control.js-attachment-limit-unit(data-field="attachmentsUploadMaxBytes")
|
||||
each attachmentTransferLimitUnitOptions 'attachmentsUploadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
|
||||
li
|
||||
.title {{_ 'attachment-download-limit-label'}}
|
||||
.form-group
|
||||
select.wekan-form-control.js-attachment-limit-mode(data-field="attachmentsDownloadMaxBytes")
|
||||
each attachmentTransferLimitModeOptions 'attachmentsDownloadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
.form-group(class="{{#if isAttachmentLimitMode 'attachmentsDownloadMaxBytes' 'max-size'}}{{else}}hide{{/if}}")
|
||||
input.wekan-form-control#attachments-download-limit-value(type="number" min="1" step="any" value="{{attachmentTransferLimitValue 'attachmentsDownloadMaxBytes'}}")
|
||||
select.wekan-form-control.js-attachment-limit-unit(data-field="attachmentsDownloadMaxBytes")
|
||||
each attachmentTransferLimitUnitOptions 'attachmentsDownloadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
|
||||
li
|
||||
.title {{_ 'api-upload-limit-label'}}
|
||||
.form-group
|
||||
select.wekan-form-control.js-attachment-limit-mode(data-field="apiUploadMaxBytes")
|
||||
each attachmentTransferLimitModeOptions 'apiUploadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
.form-group(class="{{#if isAttachmentLimitMode 'apiUploadMaxBytes' 'max-size'}}{{else}}hide{{/if}}")
|
||||
input.wekan-form-control#api-upload-limit-value(type="number" min="1" step="any" value="{{attachmentTransferLimitValue 'apiUploadMaxBytes'}}")
|
||||
select.wekan-form-control.js-attachment-limit-unit(data-field="apiUploadMaxBytes")
|
||||
each attachmentTransferLimitUnitOptions 'apiUploadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
|
||||
li
|
||||
.title {{_ 'api-download-limit-label'}}
|
||||
.form-group
|
||||
select.wekan-form-control.js-attachment-limit-mode(data-field="apiDownloadMaxBytes")
|
||||
each attachmentTransferLimitModeOptions 'apiDownloadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
.form-group(class="{{#if isAttachmentLimitMode 'apiDownloadMaxBytes' 'max-size'}}{{else}}hide{{/if}}")
|
||||
input.wekan-form-control#api-download-limit-value(type="number" min="1" step="any" value="{{attachmentTransferLimitValue 'apiDownloadMaxBytes'}}")
|
||||
select.wekan-form-control.js-attachment-limit-unit(data-field="apiDownloadMaxBytes")
|
||||
each attachmentTransferLimitUnitOptions 'apiDownloadMaxBytes'
|
||||
option(value=value selected=selected) {{_ labelKey}}
|
||||
|
||||
li
|
||||
button.js-save-attachment-transfer-limits.primary {{_ 'save'}}
|
||||
else if isFilesystemActive
|
||||
ul#attachment-filesystem-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'filesystem-storage'}}
|
||||
.storage-rw-row
|
||||
.storage-rw-item
|
||||
input.js-toggle-filesystem-read#filesystem-read(type="checkbox" checked="{{filesystemRead}}")
|
||||
label(for="filesystem-read") {{_ 'storage-read'}}
|
||||
.storage-rw-item
|
||||
input.js-toggle-filesystem-write#filesystem-write(type="checkbox" checked="{{filesystemWrite}}")
|
||||
label(for="filesystem-write") {{_ 'save'}}
|
||||
|
||||
.form-group
|
||||
button.js-calculate-filesystem-stats.btn.btn-primary(type="button") {{_ 'calculate-file-counts'}}
|
||||
if filesystemStatsLoading
|
||||
p.form-text.text-muted {{_ 'calculating-counts'}}
|
||||
if filesystemStatsError
|
||||
p.form-text.text-danger {{filesystemStatsError}}
|
||||
if hasFilesystemStats
|
||||
table.gridfs-stats-table
|
||||
tr
|
||||
th {{_ 'stats-scope'}}
|
||||
th {{_ 'stats-count'}}
|
||||
tr
|
||||
td {{_ 'attachments'}}
|
||||
td {{filesystemStats.attachments}}
|
||||
tr
|
||||
td {{_ 'avatars'}}
|
||||
td {{filesystemStats.avatars}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'writable-path'}}
|
||||
input.wekan-form-control#filesystem-path(type="text" value="{{filesystemPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'filesystem-path-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'attachments-path'}}
|
||||
input.wekan-form-control#attachments-path(type="text" value="{{attachmentsPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'attachments-path-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'avatars-path'}}
|
||||
input.wekan-form-control#avatars-path(type="text" value="{{avatarsPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'avatars-path-description'}}
|
||||
|
||||
else if isGridFsActive
|
||||
ul#attachment-gridfs-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'mongodb-gridfs-storage'}}
|
||||
.storage-rw-row
|
||||
.storage-rw-item
|
||||
input.js-toggle-gridfs-read#gridfs-read(type="checkbox" checked="{{gridfsRead}}")
|
||||
label(for="gridfs-read") {{_ 'storage-read'}}
|
||||
.storage-rw-item
|
||||
input.js-toggle-gridfs-write#gridfs-write(type="checkbox" checked="{{gridfsWrite}}")
|
||||
label(for="gridfs-write") {{_ 'save'}}
|
||||
.form-group
|
||||
button.js-calculate-gridfs-stats.btn.btn-primary(type="button") {{_ 'calculate-file-counts'}}
|
||||
if gridFsStatsLoading
|
||||
p.form-text.text-muted {{_ 'calculating-counts'}}
|
||||
if gridFsStatsError
|
||||
p.form-text.text-danger {{gridFsStatsError}}
|
||||
if hasGridFsStats
|
||||
table.gridfs-stats-table
|
||||
tr
|
||||
th {{_ 'stats-scope'}}
|
||||
th {{_ 'stats-collectionfs'}}
|
||||
th {{_ 'stats-mongo-files'}}
|
||||
tr
|
||||
td {{_ 'attachments'}}
|
||||
td {{gridFsStats.attachments.collectionFs}}
|
||||
td {{gridFsStats.attachments.mongoFiles}}
|
||||
tr
|
||||
td {{_ 'avatars'}}
|
||||
td {{gridFsStats.avatars.collectionFs}}
|
||||
td {{gridFsStats.avatars.mongoFiles}}
|
||||
.form-group
|
||||
.compact-block
|
||||
h4 {{_ 'mongodb-compact'}}
|
||||
p.form-text {{_ 'mongodb-compact-description'}}
|
||||
p.form-text.text-muted {{_ 'mongodb-compact-warning'}}
|
||||
button.js-compact-mongodb-gridfs.btn.btn-warning(type="button") {{_ 'mongodb-compact-run'}}
|
||||
if compactLoading
|
||||
p.form-text.text-muted {{_ 'mongodb-compact-running'}}
|
||||
if compactError
|
||||
p.form-text.text-danger {{_ 'mongodb-compact-error'}} {{compactError}}
|
||||
if compactResult
|
||||
p.form-text.text-success {{_ 'mongodb-compact-success'}}
|
||||
ul.compact-result-list
|
||||
each item in compactResultItems
|
||||
li
|
||||
strong {{item.name}}
|
||||
| : {{item.status}}
|
||||
|
||||
else if isS3Active
|
||||
ul#attachment-s3-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 's3-minio-storage'}}
|
||||
.storage-rw-row
|
||||
.storage-rw-item
|
||||
input#s3-read(type="checkbox" checked="{{s3Read}}" disabled)
|
||||
label(for="s3-read") {{_ 'storage-read'}}
|
||||
.storage-rw-item
|
||||
input#s3-write(type="checkbox" checked="{{s3Write}}" disabled)
|
||||
label(for="s3-write") {{_ 'save'}}
|
||||
|
||||
.form-group
|
||||
button.js-calculate-s3-stats.btn.btn-primary(type="button") {{_ 'calculate-file-counts'}}
|
||||
if s3StatsLoading
|
||||
p.form-text.text-muted {{_ 'calculating-counts'}}
|
||||
if s3StatsError
|
||||
p.form-text.text-danger {{s3StatsError}}
|
||||
if hasS3Stats
|
||||
table.gridfs-stats-table
|
||||
tr
|
||||
th {{_ 'stats-scope'}}
|
||||
th {{_ 'stats-count'}}
|
||||
tr
|
||||
td {{_ 'attachments'}}
|
||||
td {{s3Stats.attachments}}
|
||||
tr
|
||||
td {{_ 'avatars'}}
|
||||
td {{s3Stats.avatars}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-endpoint'}}
|
||||
input.wekan-form-control#s3-endpoint(type="text" value="{{s3Endpoint}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-endpoint-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-bucket'}}
|
||||
input.wekan-form-control#s3-bucket(type="text" value="{{s3Bucket}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-bucket-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-region'}}
|
||||
input.wekan-form-control#s3-region(type="text" value="{{s3Region}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-region-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-access-key'}}
|
||||
input.wekan-form-control#s3-access-key(type="text" placeholder="{{_ 's3-access-key-placeholder'}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-access-key-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-secret-key'}}
|
||||
input.wekan-form-control#s3-secret-key(type="password" placeholder="{{_ 's3-secret-key-placeholder'}}")
|
||||
small.form-text.text-muted {{_ 's3-secret-key-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-ssl-enabled'}}
|
||||
input.wekan-form-control#s3-ssl-enabled(type="checkbox" checked="{{s3SslEnabled}}" disabled)
|
||||
small.form-text.text-muted {{_ 's3-ssl-enabled-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-port'}}
|
||||
input.wekan-form-control#s3-port(type="number" value="{{s3Port}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-port-description'}}
|
||||
|
||||
.form-group
|
||||
button.js-test-s3-connection.btn.btn-secondary {{_ 'test-s3-connection'}}
|
||||
button.js-save-s3-settings.btn.btn-primary {{_ 'save-s3-settings'}}
|
||||
|
||||
template(name="moveAttachments")
|
||||
.move-attachment-buttons
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-to-fs {{_ 'move-all-attachments-to-fs'}}
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-to-gridfs {{_ 'move-all-attachments-to-gridfs'}}
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-to-s3 {{_ 'move-all-attachments-to-s3'}}
|
||||
.move-storage-form
|
||||
.move-storage-selects
|
||||
.form-group
|
||||
label {{_ 'move-source'}}
|
||||
select.wekan-form-control.js-move-source-storage
|
||||
option(value="collectionfs") {{_ 'move-storage-collectionfs'}}
|
||||
option(value="gridfs") {{_ 'move-storage-gridfs'}}
|
||||
option(value="fs") {{_ 'move-storage-fs'}}
|
||||
option(value="s3") {{_ 'move-storage-s3'}}
|
||||
.form-group
|
||||
label {{_ 'move-destination'}}
|
||||
select.wekan-form-control.js-move-dest-storage
|
||||
option(value="gridfs") {{_ 'move-storage-gridfs'}}
|
||||
option(value="fs") {{_ 'move-storage-fs'}}
|
||||
option(value="s3") {{_ 'move-storage-s3'}}
|
||||
option(value="collectionfs") {{_ 'move-storage-collectionfs'}}
|
||||
button.js-move-all-attachments.btn.btn-primary {{_ 'move-all-attachments'}}
|
||||
|
||||
.move-compact-section
|
||||
h4 {{_ 'mongodb-compact'}}
|
||||
p.form-text {{_ 'mongodb-compact-description'}}
|
||||
p.form-text.text-muted {{_ 'mongodb-compact-warning'}}
|
||||
button.js-compact-mongodb-move.btn.btn-warning(type="button") {{_ 'mongodb-compact-run'}}
|
||||
if moveCompactLoading
|
||||
p.form-text.text-muted {{_ 'mongodb-compact-running'}}
|
||||
if moveCompactError
|
||||
p.form-text.text-danger {{_ 'mongodb-compact-error'}} {{moveCompactError}}
|
||||
if moveCompactResult
|
||||
p.form-text.text-success {{_ 'mongodb-compact-success'}}
|
||||
ul.compact-result-list
|
||||
each item in moveCompactResultItems
|
||||
li
|
||||
strong {{item.name}}
|
||||
| : {{item.status}}
|
||||
|
||||
if moveProgress
|
||||
.move-progress-section
|
||||
.move-progress-header
|
||||
+spinner
|
||||
.move-progress-info
|
||||
.move-progress-counter {{_ 'move-progress-file'}} {{moveProgress.current}} / {{moveProgress.total}}
|
||||
.move-progress-filename {{moveProgress.name}} ({{moveProgressSize}})
|
||||
.move-progress-bar-track
|
||||
.move-progress-bar-fill(style="{{moveProgressBarStyle}}")
|
||||
.move-progress-bar-track
|
||||
.move-progress-bar-indeterminate
|
||||
.move-progress-controls
|
||||
if moveProgressPaused
|
||||
button.js-resume-move.btn.btn-secondary {{_ 'move-progress-resume'}}
|
||||
else
|
||||
button.js-pause-move.btn.btn-secondary {{_ 'move-progress-pause'}}
|
||||
button.js-cancel-move.btn.btn-danger {{_ 'move-progress-cancel'}}
|
||||
|
||||
each board in getBoardsWithAttachments
|
||||
+moveBoardAttachments board
|
||||
@@ -34,31 +326,41 @@ template(name="moveBoardAttachments")
|
||||
.board-description
|
||||
table
|
||||
tr
|
||||
th {{_ 'board'}} ID
|
||||
th {{_ 'board-id'}}
|
||||
th {{_ 'board-title'}}
|
||||
tr
|
||||
td {{ _id }}
|
||||
td {{ title }}
|
||||
|
||||
.move-attachment-buttons
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-of-board-to-fs {{_ 'move-all-attachments-of-board-to-fs'}}
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-of-board-to-gridfs {{_ 'move-all-attachments-of-board-to-gridfs'}}
|
||||
.js-move-attachment
|
||||
button.js-move-all-attachments-of-board-to-s3 {{_ 'move-all-attachments-of-board-to-s3'}}
|
||||
.move-storage-form
|
||||
.move-storage-selects
|
||||
.form-group
|
||||
label {{_ 'move-source'}}
|
||||
select.wekan-form-control.js-move-board-source-storage
|
||||
option(value="collectionfs") {{_ 'move-storage-collectionfs'}}
|
||||
option(value="gridfs") {{_ 'move-storage-gridfs'}}
|
||||
option(value="fs") {{_ 'move-storage-fs'}}
|
||||
option(value="s3") {{_ 'move-storage-s3'}}
|
||||
.form-group
|
||||
label {{_ 'move-destination'}}
|
||||
select.wekan-form-control.js-move-board-dest-storage
|
||||
option(value="gridfs") {{_ 'move-storage-gridfs'}}
|
||||
option(value="fs") {{_ 'move-storage-fs'}}
|
||||
option(value="s3") {{_ 'move-storage-s3'}}
|
||||
option(value="collectionfs") {{_ 'move-storage-collectionfs'}}
|
||||
button.js-move-all-attachments-of-board.btn.btn-primary {{_ 'move-all-attachments'}}
|
||||
|
||||
.board-attachments
|
||||
table
|
||||
tr
|
||||
th {{_ 'card'}}-Id
|
||||
th {{_ 'attachment'}}-Id
|
||||
th {{_ 'card-id'}}
|
||||
th {{_ 'attachment-id'}}
|
||||
th {{_ 'name'}}
|
||||
th {{_ 'path'}}
|
||||
th {{_ 'version-name'}}
|
||||
th {{_ 'size'}}
|
||||
th GridFsFileId
|
||||
th S3FileId
|
||||
th {{_ 'gridfs-file-id'}}
|
||||
th {{_ 's3-file-id'}}
|
||||
th {{_ 'storage'}}
|
||||
th {{_ 'action'}}
|
||||
|
||||
|
||||
@@ -1,54 +1,613 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { groupBy } from '/imports/lib/collectionHelpers';
|
||||
import Attachments from '/models/attachments';
|
||||
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
const { filesize } = require('filesize');
|
||||
|
||||
const LIMIT_UNIT_FACTORS = {
|
||||
bytes: 1,
|
||||
mb: 1024 * 1024,
|
||||
gb: 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const LIMIT_MODES = {
|
||||
UNLIMITED: 'unlimited',
|
||||
MAX_SIZE: 'max-size',
|
||||
BLOCKED: 'blocked',
|
||||
};
|
||||
|
||||
const LIMIT_BLOCKED_FIELDS = {
|
||||
attachmentsUploadMaxBytes: 'attachmentsUploadBlocked',
|
||||
attachmentsDownloadMaxBytes: 'attachmentsDownloadBlocked',
|
||||
apiUploadMaxBytes: 'apiUploadBlocked',
|
||||
apiDownloadMaxBytes: 'apiDownloadBlocked',
|
||||
};
|
||||
|
||||
const DEFAULT_LIMIT_SETTINGS = {
|
||||
attachmentsUploadMaxBytes: 0,
|
||||
attachmentsDownloadMaxBytes: 0,
|
||||
apiUploadMaxBytes: 0,
|
||||
apiDownloadMaxBytes: 0,
|
||||
};
|
||||
|
||||
function toNonNegativeInteger(value, fallback = 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeLimitSettings(settingsDoc) {
|
||||
const fromDoc = settingsDoc?.limitSettings || {};
|
||||
const legacyUpload = settingsDoc?.uploadSettings?.maxFileSize;
|
||||
|
||||
const attachmentsUploadMaxBytes = Number.isFinite(fromDoc.attachmentsUploadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.attachmentsUploadMaxBytes, DEFAULT_LIMIT_SETTINGS.attachmentsUploadMaxBytes)
|
||||
: (Number.isFinite(legacyUpload)
|
||||
? toNonNegativeInteger(legacyUpload, DEFAULT_LIMIT_SETTINGS.attachmentsUploadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.attachmentsUploadMaxBytes);
|
||||
|
||||
return {
|
||||
attachmentsUploadMaxBytes,
|
||||
attachmentsDownloadMaxBytes: Number.isFinite(fromDoc.attachmentsDownloadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.attachmentsDownloadMaxBytes, DEFAULT_LIMIT_SETTINGS.attachmentsDownloadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.attachmentsDownloadMaxBytes,
|
||||
apiUploadMaxBytes: Number.isFinite(fromDoc.apiUploadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.apiUploadMaxBytes, DEFAULT_LIMIT_SETTINGS.apiUploadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.apiUploadMaxBytes,
|
||||
apiDownloadMaxBytes: Number.isFinite(fromDoc.apiDownloadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.apiDownloadMaxBytes, DEFAULT_LIMIT_SETTINGS.apiDownloadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.apiDownloadMaxBytes,
|
||||
attachmentsUploadBlocked: fromDoc.attachmentsUploadBlocked === true,
|
||||
attachmentsDownloadBlocked: fromDoc.attachmentsDownloadBlocked === true,
|
||||
apiUploadBlocked: fromDoc.apiUploadBlocked === true,
|
||||
apiDownloadBlocked: fromDoc.apiDownloadBlocked === true,
|
||||
};
|
||||
}
|
||||
|
||||
function getBlockedFieldName(fieldName) {
|
||||
return LIMIT_BLOCKED_FIELDS[fieldName] || null;
|
||||
}
|
||||
|
||||
function pickModeForLimit(fieldName, normalizedLimits) {
|
||||
const blockedField = getBlockedFieldName(fieldName);
|
||||
if (blockedField && normalizedLimits[blockedField] === true) {
|
||||
return LIMIT_MODES.BLOCKED;
|
||||
}
|
||||
return normalizedLimits[fieldName] > 0 ? LIMIT_MODES.MAX_SIZE : LIMIT_MODES.UNLIMITED;
|
||||
}
|
||||
|
||||
function pickUnitForBytes(bytes) {
|
||||
const safeBytes = toNonNegativeInteger(bytes, 0);
|
||||
if (safeBytes > 0 && safeBytes % LIMIT_UNIT_FACTORS.gb === 0) {
|
||||
return 'gb';
|
||||
}
|
||||
if (safeBytes > 0 && safeBytes % LIMIT_UNIT_FACTORS.mb === 0) {
|
||||
return 'mb';
|
||||
}
|
||||
return 'bytes';
|
||||
}
|
||||
|
||||
function toDisplayValue(bytes, unit) {
|
||||
const safeBytes = toNonNegativeInteger(bytes, 0);
|
||||
const factor = LIMIT_UNIT_FACTORS[unit] || LIMIT_UNIT_FACTORS.bytes;
|
||||
return safeBytes / factor;
|
||||
}
|
||||
|
||||
function toBytes(value, unit) {
|
||||
const numericValue = Number.parseFloat(value);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return null;
|
||||
}
|
||||
const factor = LIMIT_UNIT_FACTORS[unit] || LIMIT_UNIT_FACTORS.bytes;
|
||||
return Math.round(numericValue * factor);
|
||||
}
|
||||
|
||||
function getLimitUnitOptions(selectedUnit) {
|
||||
return [
|
||||
{ value: 'gb', labelKey: 'attachment-limit-unit-gb', selected: selectedUnit === 'gb' },
|
||||
{ value: 'mb', labelKey: 'attachment-limit-unit-mb', selected: selectedUnit === 'mb' },
|
||||
{ value: 'bytes', labelKey: 'attachment-limit-unit-bytes', selected: selectedUnit === 'bytes' },
|
||||
];
|
||||
}
|
||||
|
||||
function getLimitModeOptions(selectedMode) {
|
||||
return [
|
||||
{ value: LIMIT_MODES.UNLIMITED, labelKey: 'attachment-limit-mode-unlimited', selected: selectedMode === LIMIT_MODES.UNLIMITED },
|
||||
{ value: LIMIT_MODES.MAX_SIZE, labelKey: 'attachment-limit-mode-max-size', selected: selectedMode === LIMIT_MODES.MAX_SIZE },
|
||||
{ value: LIMIT_MODES.BLOCKED, labelKey: 'attachment-limit-mode-blocked', selected: selectedMode === LIMIT_MODES.BLOCKED },
|
||||
];
|
||||
}
|
||||
|
||||
function updateStorageConfigField(tpl, storageName, field, value, checkboxEl) {
|
||||
const currentSettings = tpl.attachmentStorageSettings.get();
|
||||
if (!currentSettings) return;
|
||||
const nextSettings = {
|
||||
...currentSettings,
|
||||
storageConfig: {
|
||||
...(currentSettings.storageConfig || {}),
|
||||
[storageName]: {
|
||||
...(currentSettings.storageConfig?.[storageName] || {}),
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
};
|
||||
Meteor.call('updateAttachmentStorageSettings', nextSettings, (error) => {
|
||||
if (error) {
|
||||
alert(`${TAPi18n.__('attachment-transfer-limits-save-failed')}: ${error.reason || error.message}`);
|
||||
if (checkboxEl) checkboxEl.checked = !value;
|
||||
return;
|
||||
}
|
||||
refreshAttachmentStorageSettings(tpl);
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAttachmentStorageSettings(tpl) {
|
||||
Meteor.call('getAttachmentStorageSettings', (error, settings) => {
|
||||
if (error || !settings) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Failed to load attachment storage settings:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLimits = normalizeLimitSettings(settings);
|
||||
tpl.attachmentStorageSettings.set({
|
||||
...settings,
|
||||
limitSettings: normalizedLimits,
|
||||
});
|
||||
|
||||
tpl.attachmentLimitUnits.set({
|
||||
attachmentsUploadMaxBytes: pickUnitForBytes(normalizedLimits.attachmentsUploadMaxBytes),
|
||||
attachmentsDownloadMaxBytes: pickUnitForBytes(normalizedLimits.attachmentsDownloadMaxBytes),
|
||||
apiUploadMaxBytes: pickUnitForBytes(normalizedLimits.apiUploadMaxBytes),
|
||||
apiDownloadMaxBytes: pickUnitForBytes(normalizedLimits.apiDownloadMaxBytes),
|
||||
});
|
||||
|
||||
tpl.attachmentLimitModes.set({
|
||||
attachmentsUploadMaxBytes: pickModeForLimit('attachmentsUploadMaxBytes', normalizedLimits),
|
||||
attachmentsDownloadMaxBytes: pickModeForLimit('attachmentsDownloadMaxBytes', normalizedLimits),
|
||||
apiUploadMaxBytes: pickModeForLimit('apiUploadMaxBytes', normalizedLimits),
|
||||
apiDownloadMaxBytes: pickModeForLimit('apiDownloadMaxBytes', normalizedLimits),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Template.attachments.onCreated(function () {
|
||||
this.subscription = null;
|
||||
this.showMoveAttachments = new ReactiveVar(false);
|
||||
this.sessionId = null;
|
||||
this.error = new ReactiveVar('');
|
||||
this.activeSection = new ReactiveVar('move');
|
||||
this.attachmentsSubscription = Meteor.subscribe('attachmentsList');
|
||||
this.storageSettingsSubscription = Meteor.subscribe('attachmentStorageSettings');
|
||||
this.attachmentStorageSettings = new ReactiveVar(null);
|
||||
this.attachmentLimitUnits = new ReactiveVar({
|
||||
attachmentsUploadMaxBytes: 'mb',
|
||||
attachmentsDownloadMaxBytes: 'mb',
|
||||
apiUploadMaxBytes: 'mb',
|
||||
apiDownloadMaxBytes: 'mb',
|
||||
});
|
||||
this.attachmentLimitModes = new ReactiveVar({
|
||||
attachmentsUploadMaxBytes: LIMIT_MODES.UNLIMITED,
|
||||
attachmentsDownloadMaxBytes: LIMIT_MODES.UNLIMITED,
|
||||
apiUploadMaxBytes: LIMIT_MODES.UNLIMITED,
|
||||
apiDownloadMaxBytes: LIMIT_MODES.UNLIMITED,
|
||||
});
|
||||
this.gridFsStats = new ReactiveVar(null);
|
||||
this.gridFsStatsLoading = new ReactiveVar(false);
|
||||
this.gridFsStatsError = new ReactiveVar('');
|
||||
this.filesystemStats = new ReactiveVar(null);
|
||||
this.filesystemStatsLoading = new ReactiveVar(false);
|
||||
this.filesystemStatsError = new ReactiveVar('');
|
||||
this.s3Stats = new ReactiveVar(null);
|
||||
this.s3StatsLoading = new ReactiveVar(false);
|
||||
this.s3StatsError = new ReactiveVar('');
|
||||
this.compactLoading = new ReactiveVar(false);
|
||||
this.compactResult = new ReactiveVar(null);
|
||||
this.compactError = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
|
||||
this.autorun(() => {
|
||||
const ready = this.attachmentsSubscription.ready() && this.storageSettingsSubscription.ready();
|
||||
if (ready) {
|
||||
refreshAttachmentStorageSettings(this);
|
||||
this.loading.set(false);
|
||||
} else {
|
||||
this.loading.set(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Template.attachments.helpers({
|
||||
loading() {
|
||||
return Template.instance().loading;
|
||||
},
|
||||
showMoveAttachments() {
|
||||
return Template.instance().showMoveAttachments;
|
||||
isLimitsActive() {
|
||||
return Template.instance().activeSection.get() === 'limits';
|
||||
},
|
||||
isMoveActive() {
|
||||
return Template.instance().activeSection.get() === 'move';
|
||||
},
|
||||
isGridFsActive() {
|
||||
return Template.instance().activeSection.get() === 'gridfs';
|
||||
},
|
||||
isFilesystemActive() {
|
||||
return Template.instance().activeSection.get() === 'filesystem';
|
||||
},
|
||||
isS3Active() {
|
||||
return Template.instance().activeSection.get() === 's3';
|
||||
},
|
||||
filesystemPath() {
|
||||
return process.env.WRITABLE_PATH || '/data';
|
||||
},
|
||||
attachmentsPath() {
|
||||
const writablePath = process.env.WRITABLE_PATH || '/data';
|
||||
return `${writablePath}/attachments`;
|
||||
},
|
||||
avatarsPath() {
|
||||
const writablePath = process.env.WRITABLE_PATH || '/data';
|
||||
return `${writablePath}/avatars`;
|
||||
},
|
||||
filesystemEnabled() {
|
||||
const tpl = Template.instance();
|
||||
const settings = tpl.attachmentStorageSettings.get();
|
||||
if (settings?.storageConfig?.filesystem) {
|
||||
return settings.storageConfig.filesystem.enabled !== false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
filesystemRead() {
|
||||
const settings = Template.instance().attachmentStorageSettings.get();
|
||||
return settings?.storageConfig?.filesystem?.read !== false;
|
||||
},
|
||||
filesystemWrite() {
|
||||
const settings = Template.instance().attachmentStorageSettings.get();
|
||||
return settings?.storageConfig?.filesystem?.write !== false;
|
||||
},
|
||||
gridfsEnabled() {
|
||||
const tpl = Template.instance();
|
||||
const settings = tpl.attachmentStorageSettings.get();
|
||||
if (settings?.storageConfig?.gridfs) {
|
||||
return settings.storageConfig.gridfs.enabled !== false;
|
||||
}
|
||||
return process.env.GRIDFS_ENABLED === 'true';
|
||||
},
|
||||
gridfsRead() {
|
||||
const settings = Template.instance().attachmentStorageSettings.get();
|
||||
return settings?.storageConfig?.gridfs?.read !== false;
|
||||
},
|
||||
gridfsWrite() {
|
||||
const settings = Template.instance().attachmentStorageSettings.get();
|
||||
return settings?.storageConfig?.gridfs?.write !== false;
|
||||
},
|
||||
s3Read() {
|
||||
return process.env.S3_ENABLED === 'true';
|
||||
},
|
||||
s3Write() {
|
||||
return process.env.S3_ENABLED === 'true';
|
||||
},
|
||||
compactLoading() {
|
||||
return Template.instance().compactLoading.get();
|
||||
},
|
||||
compactResult() {
|
||||
return Template.instance().compactResult.get();
|
||||
},
|
||||
compactError() {
|
||||
return Template.instance().compactError.get();
|
||||
},
|
||||
compactResultItems() {
|
||||
const result = Template.instance().compactResult.get();
|
||||
if (!result) return [];
|
||||
const items = [];
|
||||
for (const [node, nodeData] of Object.entries(result)) {
|
||||
if (nodeData && typeof nodeData === 'object') {
|
||||
for (const [collName, status] of Object.entries(nodeData)) {
|
||||
items.push({ name: `[${node}] ${collName}`, status });
|
||||
}
|
||||
} else {
|
||||
items.push({ name: node, status: String(nodeData) });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
},
|
||||
hasGridFsStats() {
|
||||
return !!Template.instance().gridFsStats.get();
|
||||
},
|
||||
gridFsStats() {
|
||||
return Template.instance().gridFsStats.get();
|
||||
},
|
||||
gridFsStatsLoading() {
|
||||
return Template.instance().gridFsStatsLoading.get();
|
||||
},
|
||||
gridFsStatsError() {
|
||||
return Template.instance().gridFsStatsError.get();
|
||||
},
|
||||
hasFilesystemStats() {
|
||||
return !!Template.instance().filesystemStats.get();
|
||||
},
|
||||
filesystemStats() {
|
||||
return Template.instance().filesystemStats.get();
|
||||
},
|
||||
filesystemStatsLoading() {
|
||||
return Template.instance().filesystemStatsLoading.get();
|
||||
},
|
||||
filesystemStatsError() {
|
||||
return Template.instance().filesystemStatsError.get();
|
||||
},
|
||||
hasS3Stats() {
|
||||
return !!Template.instance().s3Stats.get();
|
||||
},
|
||||
s3Stats() {
|
||||
return Template.instance().s3Stats.get();
|
||||
},
|
||||
s3StatsLoading() {
|
||||
return Template.instance().s3StatsLoading.get();
|
||||
},
|
||||
s3StatsError() {
|
||||
return Template.instance().s3StatsError.get();
|
||||
},
|
||||
s3Enabled() {
|
||||
return process.env.S3_ENABLED === 'true';
|
||||
},
|
||||
s3Endpoint() {
|
||||
return process.env.S3_ENDPOINT || '';
|
||||
},
|
||||
s3Bucket() {
|
||||
return process.env.S3_BUCKET || '';
|
||||
},
|
||||
s3Region() {
|
||||
return process.env.S3_REGION || '';
|
||||
},
|
||||
s3SslEnabled() {
|
||||
return process.env.S3_SSL_ENABLED === 'true';
|
||||
},
|
||||
s3Port() {
|
||||
return process.env.S3_PORT || 443;
|
||||
},
|
||||
attachmentTransferLimitValue(fieldName) {
|
||||
const tpl = Template.instance();
|
||||
const settingsDoc = tpl.attachmentStorageSettings.get();
|
||||
const units = tpl.attachmentLimitUnits.get() || {};
|
||||
const limits = normalizeLimitSettings(settingsDoc);
|
||||
const unit = units[fieldName] || 'bytes';
|
||||
return toDisplayValue(limits[fieldName], unit);
|
||||
},
|
||||
attachmentTransferLimitUnitOptions(fieldName) {
|
||||
const tpl = Template.instance();
|
||||
const units = tpl.attachmentLimitUnits.get() || {};
|
||||
const selectedUnit = units[fieldName] || 'bytes';
|
||||
return getLimitUnitOptions(selectedUnit);
|
||||
},
|
||||
attachmentTransferLimitModeOptions(fieldName) {
|
||||
const tpl = Template.instance();
|
||||
const modeMap = tpl.attachmentLimitModes.get() || {};
|
||||
return getLimitModeOptions(modeMap[fieldName] || LIMIT_MODES.UNLIMITED);
|
||||
},
|
||||
isAttachmentLimitMode(fieldName, expectedMode) {
|
||||
const tpl = Template.instance();
|
||||
const modeMap = tpl.attachmentLimitModes.get() || {};
|
||||
return modeMap[fieldName] === expectedMode;
|
||||
},
|
||||
});
|
||||
|
||||
Template.attachments.events({
|
||||
'click a.js-move-attachments'(event, tpl) {
|
||||
const target = $(event.target);
|
||||
if (!target.hasClass('active')) {
|
||||
tpl.loading.set(true);
|
||||
tpl.showMoveAttachments.set(false);
|
||||
if (tpl.subscription) {
|
||||
tpl.subscription.stop();
|
||||
}
|
||||
|
||||
$('.side-menu li.active').removeClass('active');
|
||||
target.parent().addClass('active');
|
||||
const targetID = target.data('id');
|
||||
|
||||
if ('move-attachments' === targetID) {
|
||||
tpl.showMoveAttachments.set(true);
|
||||
tpl.subscription = Meteor.subscribe('attachmentsList', () => {
|
||||
tpl.loading.set(false);
|
||||
});
|
||||
}
|
||||
'click a.js-attachments-menu'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const target = $(event.currentTarget);
|
||||
const targetID = target.data('id');
|
||||
if (!targetID) {
|
||||
return;
|
||||
}
|
||||
|
||||
tpl.activeSection.set(targetID);
|
||||
},
|
||||
'change select.js-attachment-limit-unit'(event, tpl) {
|
||||
const fieldName = event.currentTarget.dataset.field;
|
||||
const selectedUnit = event.currentTarget.value;
|
||||
if (!fieldName || !selectedUnit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = tpl.attachmentLimitUnits.get() || {};
|
||||
tpl.attachmentLimitUnits.set({
|
||||
...current,
|
||||
[fieldName]: selectedUnit,
|
||||
});
|
||||
},
|
||||
'change select.js-attachment-limit-mode'(event, tpl) {
|
||||
const fieldName = event.currentTarget.dataset.field;
|
||||
const selectedMode = event.currentTarget.value;
|
||||
if (!fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = tpl.attachmentLimitModes.get() || {};
|
||||
tpl.attachmentLimitModes.set({
|
||||
...current,
|
||||
[fieldName]: selectedMode,
|
||||
});
|
||||
},
|
||||
'click button.js-save-attachment-transfer-limits'(event, tpl) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentSettings = tpl.attachmentStorageSettings.get();
|
||||
if (!currentSettings) {
|
||||
alert(TAPi18n.__('attachment-transfer-limits-save-failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUnits = tpl.attachmentLimitUnits.get() || {};
|
||||
const modeMap = tpl.attachmentLimitModes.get() || {};
|
||||
const fieldConfig = [
|
||||
{ fieldName: 'attachmentsUploadMaxBytes', inputId: '#attachments-upload-limit-value' },
|
||||
{ fieldName: 'attachmentsDownloadMaxBytes', inputId: '#attachments-download-limit-value' },
|
||||
{ fieldName: 'apiUploadMaxBytes', inputId: '#api-upload-limit-value' },
|
||||
{ fieldName: 'apiDownloadMaxBytes', inputId: '#api-download-limit-value' },
|
||||
];
|
||||
|
||||
const nextLimitSettings = {};
|
||||
for (const field of fieldConfig) {
|
||||
const selectedMode = modeMap[field.fieldName] || LIMIT_MODES.UNLIMITED;
|
||||
const blockedFieldName = getBlockedFieldName(field.fieldName);
|
||||
|
||||
if (selectedMode === LIMIT_MODES.BLOCKED) {
|
||||
if (blockedFieldName) {
|
||||
nextLimitSettings[blockedFieldName] = true;
|
||||
}
|
||||
nextLimitSettings[field.fieldName] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockedFieldName) {
|
||||
nextLimitSettings[blockedFieldName] = false;
|
||||
}
|
||||
|
||||
if (selectedMode === LIMIT_MODES.UNLIMITED) {
|
||||
nextLimitSettings[field.fieldName] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const unit = currentUnits[field.fieldName] || 'bytes';
|
||||
const value = $(field.inputId).val();
|
||||
const bytesValue = toBytes(value, unit);
|
||||
if (bytesValue === null || bytesValue <= 0) {
|
||||
alert(TAPi18n.__('attachment-transfer-limits-invalid-value'));
|
||||
return;
|
||||
}
|
||||
nextLimitSettings[field.fieldName] = bytesValue;
|
||||
}
|
||||
|
||||
const nextSettings = {
|
||||
...currentSettings,
|
||||
uploadSettings: {
|
||||
...(currentSettings.uploadSettings || {}),
|
||||
maxFileSize: nextLimitSettings.attachmentsUploadMaxBytes,
|
||||
},
|
||||
limitSettings: {
|
||||
...(currentSettings.limitSettings || {}),
|
||||
...nextLimitSettings,
|
||||
},
|
||||
};
|
||||
|
||||
Meteor.call('updateAttachmentStorageSettings', nextSettings, (error) => {
|
||||
if (error) {
|
||||
alert(`${TAPi18n.__('attachment-transfer-limits-save-failed')}: ${error.reason || error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
alert(TAPi18n.__('attachment-transfer-limits-saved'));
|
||||
refreshAttachmentStorageSettings(tpl);
|
||||
});
|
||||
},
|
||||
'change input.js-toggle-filesystem-read'(event, tpl) {
|
||||
updateStorageConfigField(tpl, 'filesystem', 'read', event.currentTarget.checked, event.currentTarget);
|
||||
},
|
||||
'change input.js-toggle-filesystem-write'(event, tpl) {
|
||||
updateStorageConfigField(tpl, 'filesystem', 'write', event.currentTarget.checked, event.currentTarget);
|
||||
},
|
||||
'change input.js-toggle-gridfs-read'(event, tpl) {
|
||||
updateStorageConfigField(tpl, 'gridfs', 'read', event.currentTarget.checked, event.currentTarget);
|
||||
},
|
||||
'change input.js-toggle-gridfs-write'(event, tpl) {
|
||||
updateStorageConfigField(tpl, 'gridfs', 'write', event.currentTarget.checked, event.currentTarget);
|
||||
},
|
||||
'click button.js-calculate-gridfs-stats'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.gridFsStatsLoading.set(true);
|
||||
tpl.gridFsStatsError.set('');
|
||||
Meteor.call('getGridFsStorageStats', (error, result) => {
|
||||
tpl.gridFsStatsLoading.set(false);
|
||||
if (error) {
|
||||
tpl.gridFsStats.set(null);
|
||||
tpl.gridFsStatsError.set(error.reason || error.message || 'Failed to calculate counts');
|
||||
return;
|
||||
}
|
||||
tpl.gridFsStats.set(result || null);
|
||||
});
|
||||
},
|
||||
'click button.js-calculate-filesystem-stats'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.filesystemStatsLoading.set(true);
|
||||
tpl.filesystemStatsError.set('');
|
||||
Meteor.call('getFilesystemStorageStats', (error, result) => {
|
||||
tpl.filesystemStatsLoading.set(false);
|
||||
if (error) {
|
||||
tpl.filesystemStats.set(null);
|
||||
tpl.filesystemStatsError.set(error.reason || error.message || 'Failed to calculate counts');
|
||||
return;
|
||||
}
|
||||
tpl.filesystemStats.set(result || null);
|
||||
});
|
||||
},
|
||||
'click button.js-calculate-s3-stats'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.s3StatsLoading.set(true);
|
||||
tpl.s3StatsError.set('');
|
||||
Meteor.call('getS3StorageStats', (error, result) => {
|
||||
tpl.s3StatsLoading.set(false);
|
||||
if (error) {
|
||||
tpl.s3Stats.set(null);
|
||||
tpl.s3StatsError.set(error.reason || error.message || 'Failed to calculate counts');
|
||||
return;
|
||||
}
|
||||
tpl.s3Stats.set(result || null);
|
||||
});
|
||||
},
|
||||
'click button.js-compact-mongodb-gridfs'(event, tpl) {
|
||||
event.preventDefault();
|
||||
runCompact(tpl.compactLoading, tpl.compactResult, tpl.compactError);
|
||||
},
|
||||
});
|
||||
|
||||
Template.moveAttachments.onCreated(function () {
|
||||
this.attachments = null;
|
||||
this.moveProgressData = new ReactiveVar(null);
|
||||
this._moveController = null;
|
||||
this.moveCompactLoading = new ReactiveVar(false);
|
||||
this.moveCompactResult = new ReactiveVar(null);
|
||||
this.moveCompactError = new ReactiveVar('');
|
||||
});
|
||||
|
||||
Template.moveAttachments.helpers({
|
||||
moveCompactLoading() {
|
||||
return Template.instance().moveCompactLoading.get();
|
||||
},
|
||||
moveCompactResult() {
|
||||
return Template.instance().moveCompactResult.get();
|
||||
},
|
||||
moveCompactError() {
|
||||
return Template.instance().moveCompactError.get();
|
||||
},
|
||||
moveCompactResultItems() {
|
||||
const result = Template.instance().moveCompactResult.get();
|
||||
if (!result) return [];
|
||||
const items = [];
|
||||
for (const [node, nodeData] of Object.entries(result)) {
|
||||
if (nodeData && typeof nodeData === 'object') {
|
||||
for (const [collName, status] of Object.entries(nodeData)) {
|
||||
items.push({ name: `[${node}] ${collName}`, status });
|
||||
}
|
||||
} else {
|
||||
items.push({ name: node, status: String(nodeData) });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
},
|
||||
moveProgress() {
|
||||
return Template.instance().moveProgressData.get();
|
||||
},
|
||||
moveProgressPaused() {
|
||||
return Template.instance().moveProgressData.get()?.paused === true;
|
||||
},
|
||||
moveProgressBarStyle() {
|
||||
const p = Template.instance().moveProgressData.get();
|
||||
const pct = p && p.total ? Math.round((p.done / p.total) * 100) : 0;
|
||||
return `width: ${pct}%`;
|
||||
},
|
||||
moveProgressSize() {
|
||||
const p = Template.instance().moveProgressData.get();
|
||||
return p?.size ? filesize(p.size) : '';
|
||||
},
|
||||
getBoardsWithAttachments() {
|
||||
const tpl = Template.instance();
|
||||
tpl.attachments = ReactiveCache.getAttachments();
|
||||
@@ -81,41 +640,118 @@ Template.moveAttachments.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
function runCompact(loadingVar, resultVar, errorVar) {
|
||||
loadingVar.set(true);
|
||||
resultVar.set(null);
|
||||
errorVar.set('');
|
||||
Meteor.call('compactMongoGridFs', (error, result) => {
|
||||
loadingVar.set(false);
|
||||
if (error) {
|
||||
errorVar.set(error.reason || error.message || 'Compact failed');
|
||||
return;
|
||||
}
|
||||
resultVar.set(result || {});
|
||||
});
|
||||
}
|
||||
|
||||
async function runMoveAll(tpl, attachmentsToMove, destStorage) {
|
||||
const total = attachmentsToMove.length;
|
||||
const controller = tpl._moveController;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (controller.cancelled) break;
|
||||
|
||||
while (controller.paused && !controller.cancelled) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
if (controller.cancelled) break;
|
||||
|
||||
const att = attachmentsToMove[i];
|
||||
tpl.moveProgressData.set({
|
||||
total,
|
||||
done: i,
|
||||
current: i + 1,
|
||||
name: att.name || att._id,
|
||||
size: att.size || 0,
|
||||
paused: false,
|
||||
});
|
||||
|
||||
await new Promise(resolve => {
|
||||
Meteor.call('moveAttachmentToStorage', att._id, destStorage, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
tpl.moveProgressData.set(null);
|
||||
tpl._moveController = null;
|
||||
}
|
||||
|
||||
function attachmentVersionMatchesSource(attachment, source) {
|
||||
const versions = Object.values(attachment.versions || {});
|
||||
return versions.some(v => {
|
||||
switch (source) {
|
||||
case 'collectionfs':
|
||||
return !!(v.meta?.gridFsFileId);
|
||||
case 'gridfs':
|
||||
return v.storage === 'gridfs' && !v.meta?.gridFsFileId;
|
||||
case 'fs':
|
||||
return v.storage === 'fs' || (!v.storage && !v.meta?.gridFsFileId);
|
||||
case 's3':
|
||||
return v.storage === 's3';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Template.moveAttachments.events({
|
||||
'click button.js-move-all-attachments-to-fs'(event, tpl) {
|
||||
tpl.attachments.forEach(_attachment => {
|
||||
Meteor.call('moveAttachmentToStorage', _attachment._id, "fs");
|
||||
});
|
||||
'click button.js-move-all-attachments'(event, tpl) {
|
||||
if (tpl.moveProgressData.get()) return;
|
||||
const source = tpl.$('.js-move-source-storage').val();
|
||||
const dest = tpl.$('.js-move-dest-storage').val();
|
||||
if (!source || !dest || source === dest) return;
|
||||
const destStorage = dest === 'collectionfs' ? 'gridfs' : dest;
|
||||
const toMove = (tpl.attachments || []).filter(a => attachmentVersionMatchesSource(a, source));
|
||||
if (toMove.length === 0) return;
|
||||
tpl._moveController = { paused: false, cancelled: false };
|
||||
runMoveAll(tpl, toMove, destStorage);
|
||||
},
|
||||
'click button.js-move-all-attachments-to-gridfs'(event, tpl) {
|
||||
tpl.attachments.forEach(_attachment => {
|
||||
Meteor.call('moveAttachmentToStorage', _attachment._id, "gridfs");
|
||||
});
|
||||
'click button.js-pause-move'(event, tpl) {
|
||||
if (!tpl._moveController) return;
|
||||
tpl._moveController.paused = true;
|
||||
const p = tpl.moveProgressData.get();
|
||||
if (p) tpl.moveProgressData.set({ ...p, paused: true });
|
||||
},
|
||||
'click button.js-move-all-attachments-to-s3'(event, tpl) {
|
||||
tpl.attachments.forEach(_attachment => {
|
||||
Meteor.call('moveAttachmentToStorage', _attachment._id, "s3");
|
||||
});
|
||||
'click button.js-resume-move'(event, tpl) {
|
||||
if (!tpl._moveController) return;
|
||||
tpl._moveController.paused = false;
|
||||
const p = tpl.moveProgressData.get();
|
||||
if (p) tpl.moveProgressData.set({ ...p, paused: false });
|
||||
},
|
||||
'click button.js-cancel-move'(event, tpl) {
|
||||
if (tpl._moveController) {
|
||||
tpl._moveController.cancelled = true;
|
||||
tpl._moveController.paused = false;
|
||||
}
|
||||
tpl.moveProgressData.set(null);
|
||||
tpl._moveController = null;
|
||||
},
|
||||
'click button.js-compact-mongodb-move'(event, tpl) {
|
||||
event.preventDefault();
|
||||
runCompact(tpl.moveCompactLoading, tpl.moveCompactResult, tpl.moveCompactError);
|
||||
},
|
||||
});
|
||||
|
||||
Template.moveBoardAttachments.events({
|
||||
'click button.js-move-all-attachments-of-board-to-fs'() {
|
||||
'click button.js-move-all-attachments-of-board'(event, tpl) {
|
||||
const source = tpl.$('.js-move-board-source-storage').val();
|
||||
const dest = tpl.$('.js-move-board-dest-storage').val();
|
||||
if (!source || !dest || source === dest) return;
|
||||
const destStorage = dest === 'collectionfs' ? 'gridfs' : dest;
|
||||
const data = Template.currentData();
|
||||
data.attachments.forEach(_attachment => {
|
||||
Meteor.call('moveAttachmentToStorage', _attachment._id, "fs");
|
||||
});
|
||||
},
|
||||
'click button.js-move-all-attachments-of-board-to-gridfs'() {
|
||||
const data = Template.currentData();
|
||||
data.attachments.forEach(_attachment => {
|
||||
Meteor.call('moveAttachmentToStorage', _attachment._id, "gridfs");
|
||||
});
|
||||
},
|
||||
'click button.js-move-all-attachments-of-board-to-s3'() {
|
||||
const data = Template.currentData();
|
||||
data.attachments.forEach(_attachment => {
|
||||
Meteor.call('moveAttachmentToStorage', _attachment._id, "s3");
|
||||
(data.attachments || []).forEach(_attachment => {
|
||||
if (attachmentVersionMatchesSource(_attachment, source)) {
|
||||
Meteor.call('moveAttachmentToStorage', _attachment._id, destStorage);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -354,3 +354,27 @@ table td:first-child {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Roles tab. The .main-body scrolls horizontally and is wider than the visible
|
||||
area (the side-menu takes ~250px), so by default a long description would run
|
||||
off the right edge and only be fully readable at the smallest (mobile) width.
|
||||
Pin the heading/description to the visible left edge and wrap them within the
|
||||
viewport so they stay visible at every width. */
|
||||
.roles-settings {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.roles-settings h3,
|
||||
.roles-settings h4,
|
||||
.roles-settings .roles-description {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
/* Cap to ~60 characters, and never wider than the viewport, so the text wraps
|
||||
into the visible left area at every width (desktop, mid and mobile) instead
|
||||
of running off the horizontally-scrolling main-body. */
|
||||
max-width: min(60ch, 88vw);
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.roles-settings .roles-description {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -63,6 +63,11 @@ template(name="people")
|
||||
i.fa.fa-lock
|
||||
unless isMiniScreen
|
||||
| {{_ 'accounts-lockout-locked-users'}}
|
||||
else if rolesSetting.get
|
||||
span
|
||||
i.fa.fa-key
|
||||
unless isMiniScreen
|
||||
| {{_ 'roles'}}
|
||||
|
||||
.content-body
|
||||
.side-menu
|
||||
@@ -84,6 +89,10 @@ template(name="people")
|
||||
span.emoji-icon.text-red
|
||||
i.fa.fa-lock
|
||||
| {{_ 'accounts-lockout-locked-users'}}
|
||||
li
|
||||
a.js-roles-menu(data-id="roles-setting")
|
||||
i.fa.fa-key
|
||||
| {{_ 'roles'}}
|
||||
.main-body
|
||||
if loading.get
|
||||
+spinner
|
||||
@@ -95,6 +104,8 @@ template(name="people")
|
||||
+peopleGeneral(peopleList=peopleList peopleCurrentPage=peopleCurrentPage peopleTotalPages=peopleTotalPages hasPeoplePrevPage=hasPeoplePrevPage hasPeopleNextPage=hasPeopleNextPage)
|
||||
else if lockedUsersSetting.get
|
||||
+lockedUsersGeneral
|
||||
else if rolesSetting.get
|
||||
+rolesGeneral
|
||||
|
||||
|
||||
template(name="orgGeneral")
|
||||
@@ -719,3 +730,22 @@ template(name="lockedUsersGeneral")
|
||||
input.wekan-form-control#unknown-failure-window(type="number", min="1", max="60", placeholder="15" value="{{unknownFailureWindow}}")
|
||||
|
||||
button.js-lockout-save.primary {{_ 'save'}}
|
||||
|
||||
template(name="rolesGeneral")
|
||||
.roles-settings
|
||||
h3 {{_ 'roles'}}
|
||||
p.roles-description {{_ 'roles-info'}}
|
||||
|
||||
h4 {{_ 'allow-invite-to-board'}}
|
||||
ul#roles-setting.setting-detail
|
||||
li
|
||||
a.flex.js-toggle-all-roles
|
||||
.materialCheckBox(class="{{#if allRolesAllowed}}is-checked{{/if}}")
|
||||
span {{_ 'all-board-members'}}
|
||||
each roleOptions
|
||||
li
|
||||
a.flex.js-toggle-role(data-role="{{key}}")
|
||||
.materialCheckBox(class="{{#if allowed}}is-checked{{/if}}")
|
||||
span {{_ label}}
|
||||
|
||||
button.js-roles-save.primary {{_ 'save'}}
|
||||
@@ -6,6 +6,10 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Org from '/models/org';
|
||||
import Team from '/models/team';
|
||||
import Users from '/models/users';
|
||||
import InviteToBoardRolesSettings, {
|
||||
INVITE_TO_BOARD_ROLES,
|
||||
INVITE_TO_BOARD_ROLES_ID,
|
||||
} from '/models/inviteToBoardRolesSettings';
|
||||
|
||||
const orgsPerPage = 25;
|
||||
const teamsPerPage = 25;
|
||||
@@ -22,6 +26,8 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting = new ReactiveVar(false);
|
||||
this.peopleSetting = new ReactiveVar(false);
|
||||
this.lockedUsersSetting = new ReactiveVar(false);
|
||||
this.rolesSetting = new ReactiveVar(false);
|
||||
this.subscribe('inviteToBoardRolesSettings');
|
||||
this.findOrgsOptions = new ReactiveVar({});
|
||||
this.findTeamsOptions = new ReactiveVar({});
|
||||
this.findUsersOptions = new ReactiveVar({});
|
||||
@@ -191,6 +197,7 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting.set('team-setting' === targetID);
|
||||
this.peopleSetting.set('people-setting' === targetID);
|
||||
this.lockedUsersSetting.set('locked-users-setting' === targetID);
|
||||
this.rolesSetting.set('roles-setting' === targetID);
|
||||
|
||||
// When switching to locked users tab, refresh the locked users list
|
||||
if ('locked-users-setting' === targetID) {
|
||||
@@ -263,6 +270,9 @@ Template.people.helpers({
|
||||
lockedUsersSetting() {
|
||||
return Template.instance().lockedUsersSetting;
|
||||
},
|
||||
rolesSetting() {
|
||||
return Template.instance().rolesSetting;
|
||||
},
|
||||
orgList() {
|
||||
const tpl = Template.instance();
|
||||
const page = tpl.orgPage.get();
|
||||
@@ -521,6 +531,66 @@ Template.people.events({
|
||||
'click a.js-locked-users-menu'(event, tpl) {
|
||||
tpl.switchMenu(event);
|
||||
},
|
||||
'click a.js-roles-menu'(event, tpl) {
|
||||
tpl.switchMenu(event);
|
||||
},
|
||||
});
|
||||
|
||||
Template.rolesGeneral.onCreated(function () {
|
||||
// Working copy of the allowed-roles set; null until the published doc loads.
|
||||
this.workingRoles = new ReactiveVar(null);
|
||||
this.autorun(() => {
|
||||
if (this.workingRoles.get() === null) {
|
||||
const doc = InviteToBoardRolesSettings.findOne(INVITE_TO_BOARD_ROLES_ID);
|
||||
if (doc) {
|
||||
this.workingRoles.set((doc.allowedRoles || []).slice());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Template.rolesGeneral.helpers({
|
||||
roleOptions() {
|
||||
const working = Template.instance().workingRoles.get() || [];
|
||||
// The role key doubles as the i18n key. 'board-admin' renders as
|
||||
// "Board Admin", deliberately distinct from the global Admin Panel admin.
|
||||
return INVITE_TO_BOARD_ROLES.map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
allowed: working.includes(key),
|
||||
}));
|
||||
},
|
||||
allRolesAllowed() {
|
||||
const working = Template.instance().workingRoles.get() || [];
|
||||
return INVITE_TO_BOARD_ROLES.every((key) => working.includes(key));
|
||||
},
|
||||
});
|
||||
|
||||
Template.rolesGeneral.events({
|
||||
'click a.js-toggle-role'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const role = $(event.currentTarget).data('role');
|
||||
const working = (tpl.workingRoles.get() || []).slice();
|
||||
const idx = working.indexOf(role);
|
||||
if (idx >= 0) {
|
||||
working.splice(idx, 1);
|
||||
} else {
|
||||
working.push(role);
|
||||
}
|
||||
tpl.workingRoles.set(working);
|
||||
},
|
||||
'click a.js-toggle-all-roles'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const working = tpl.workingRoles.get() || [];
|
||||
const allOn = INVITE_TO_BOARD_ROLES.every((key) => working.includes(key));
|
||||
tpl.workingRoles.set(allOn ? [] : INVITE_TO_BOARD_ROLES.slice());
|
||||
},
|
||||
'click .js-roles-save'(event, tpl) {
|
||||
event.preventDefault();
|
||||
InviteToBoardRolesSettings.update(INVITE_TO_BOARD_ROLES_ID, {
|
||||
$set: { allowedRoles: tpl.workingRoles.get() || [] },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Template.orgRow.helpers({
|
||||
|
||||
@@ -249,3 +249,20 @@ li.has-error .form-group .wekan-form-control {
|
||||
max-width: calc(100vw - 240px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .attachment-limit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .attachment-limit-row input.wekan-form-control {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .attachment-limit-row select.wekan-form-control {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
@@ -44,11 +44,6 @@ template(name="setting")
|
||||
span.emoji-icon
|
||||
i.fa.fa-globe
|
||||
| {{_ 'global-webhook'}}
|
||||
else if isAttachmentSettings
|
||||
span
|
||||
span.emoji-icon
|
||||
i.fa.fa-paperclip
|
||||
| {{_ 'attachments'}}
|
||||
//- COMMENTED OUT: Migration settings title section
|
||||
.content-body
|
||||
.side-menu
|
||||
@@ -94,86 +89,10 @@ template(name="setting")
|
||||
span.emoji-icon
|
||||
i.fa.fa-globe
|
||||
| {{_ 'global-webhook'}}
|
||||
li(class="{{#if isAttachmentSettings}}active{{/if}}")
|
||||
a.js-setting-menu(data-id="attachment-settings")
|
||||
span.emoji-icon
|
||||
i.fa.fa-paperclip
|
||||
| {{_ 'attachments'}}
|
||||
//- COMMENTED OUT: Migration menu item
|
||||
.main-body
|
||||
if isLoading
|
||||
+spinner
|
||||
else if isAttachmentSettings
|
||||
ul#attachment-setting.setting-detail
|
||||
li
|
||||
h3 {{_ 'attachment-storage-configuration'}}
|
||||
.form-group
|
||||
label {{_ 'writable-path'}}
|
||||
input.wekan-form-control#filesystem-path(type="text" value="{{filesystemPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'filesystem-path-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'attachments-path'}}
|
||||
input.wekan-form-control#attachments-path(type="text" value="{{attachmentsPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'attachments-path-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 'avatars-path'}}
|
||||
input.wekan-form-control#avatars-path(type="text" value="{{avatarsPath}}" readonly)
|
||||
small.form-text.text-muted {{_ 'avatars-path-description'}}
|
||||
|
||||
li
|
||||
h3 {{_ 'mongodb-gridfs-storage'}}
|
||||
.form-group
|
||||
label {{_ 'gridfs-enabled'}}
|
||||
input.wekan-form-control#gridfs-enabled(type="checkbox" checked="{{gridfsEnabled}}" disabled)
|
||||
small.form-text.text-muted {{_ 'gridfs-enabled-description'}}
|
||||
|
||||
li
|
||||
h3 {{_ 's3-minio-storage'}}
|
||||
.form-group
|
||||
label {{_ 's3-enabled'}}
|
||||
input.wekan-form-control#s3-enabled(type="checkbox" checked="{{s3Enabled}}" disabled)
|
||||
small.form-text.text-muted {{_ 's3-enabled-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-endpoint'}}
|
||||
input.wekan-form-control#s3-endpoint(type="text" value="{{s3Endpoint}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-endpoint-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-bucket'}}
|
||||
input.wekan-form-control#s3-bucket(type="text" value="{{s3Bucket}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-bucket-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-region'}}
|
||||
input.wekan-form-control#s3-region(type="text" value="{{s3Region}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-region-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-access-key'}}
|
||||
input.wekan-form-control#s3-access-key(type="text" placeholder="{{_ 's3-access-key-placeholder'}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-access-key-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-secret-key'}}
|
||||
input.wekan-form-control#s3-secret-key(type="password" placeholder="{{_ 's3-secret-key-placeholder'}}")
|
||||
small.form-text.text-muted {{_ 's3-secret-key-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-ssl-enabled'}}
|
||||
input.wekan-form-control#s3-ssl-enabled(type="checkbox" checked="{{s3SslEnabled}}" disabled)
|
||||
small.form-text.text-muted {{_ 's3-ssl-enabled-description'}}
|
||||
|
||||
.form-group
|
||||
label {{_ 's3-port'}}
|
||||
input.wekan-form-control#s3-port(type="number" value="{{s3Port}}" readonly)
|
||||
small.form-text.text-muted {{_ 's3-port-description'}}
|
||||
|
||||
.form-group
|
||||
button.js-test-s3-connection.btn.btn-secondary {{_ 'test-s3-connection'}}
|
||||
button.js-save-s3-settings.btn.btn-primary {{_ 'save-s3-settings'}}
|
||||
else if isGeneralSetting
|
||||
+general
|
||||
else if isEmailSetting
|
||||
|
||||
@@ -118,6 +118,128 @@ function cleanAndValidateJSON(content) {
|
||||
}
|
||||
}
|
||||
|
||||
const LIMIT_UNIT_FACTORS = {
|
||||
bytes: 1,
|
||||
mb: 1024 * 1024,
|
||||
gb: 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const DEFAULT_LIMIT_SETTINGS = {
|
||||
attachmentsUploadMaxBytes: 0,
|
||||
attachmentsDownloadMaxBytes: 0,
|
||||
apiUploadMaxBytes: 50 * 1024 * 1024,
|
||||
apiDownloadMaxBytes: 20 * 1024 * 1024,
|
||||
};
|
||||
|
||||
function toNonNegativeInteger(value, fallback = 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeLimitSettings(settingsDoc) {
|
||||
const fromDoc = settingsDoc?.limitSettings || {};
|
||||
const legacyUpload = settingsDoc?.uploadSettings?.maxFileSize;
|
||||
|
||||
const attachmentsUploadMaxBytes = Number.isFinite(fromDoc.attachmentsUploadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.attachmentsUploadMaxBytes, DEFAULT_LIMIT_SETTINGS.attachmentsUploadMaxBytes)
|
||||
: (Number.isFinite(legacyUpload)
|
||||
? toNonNegativeInteger(legacyUpload, DEFAULT_LIMIT_SETTINGS.attachmentsUploadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.attachmentsUploadMaxBytes);
|
||||
|
||||
return {
|
||||
attachmentsUploadMaxBytes,
|
||||
attachmentsDownloadMaxBytes: Number.isFinite(fromDoc.attachmentsDownloadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.attachmentsDownloadMaxBytes, DEFAULT_LIMIT_SETTINGS.attachmentsDownloadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.attachmentsDownloadMaxBytes,
|
||||
apiUploadMaxBytes: Number.isFinite(fromDoc.apiUploadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.apiUploadMaxBytes, DEFAULT_LIMIT_SETTINGS.apiUploadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.apiUploadMaxBytes,
|
||||
apiDownloadMaxBytes: Number.isFinite(fromDoc.apiDownloadMaxBytes)
|
||||
? toNonNegativeInteger(fromDoc.apiDownloadMaxBytes, DEFAULT_LIMIT_SETTINGS.apiDownloadMaxBytes)
|
||||
: DEFAULT_LIMIT_SETTINGS.apiDownloadMaxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function pickUnitForBytes(bytes) {
|
||||
const safeBytes = toNonNegativeInteger(bytes, 0);
|
||||
if (safeBytes > 0 && safeBytes % LIMIT_UNIT_FACTORS.gb === 0) {
|
||||
return 'gb';
|
||||
}
|
||||
if (safeBytes > 0 && safeBytes % LIMIT_UNIT_FACTORS.mb === 0) {
|
||||
return 'mb';
|
||||
}
|
||||
return 'bytes';
|
||||
}
|
||||
|
||||
function toDisplayValue(bytes, unit) {
|
||||
const safeBytes = toNonNegativeInteger(bytes, 0);
|
||||
const factor = LIMIT_UNIT_FACTORS[unit] || LIMIT_UNIT_FACTORS.bytes;
|
||||
return safeBytes / factor;
|
||||
}
|
||||
|
||||
function toBytes(value, unit) {
|
||||
const numericValue = Number.parseFloat(value);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return null;
|
||||
}
|
||||
const factor = LIMIT_UNIT_FACTORS[unit] || LIMIT_UNIT_FACTORS.bytes;
|
||||
return Math.round(numericValue * factor);
|
||||
}
|
||||
|
||||
function refreshAttachmentStorageSettings(tpl, showLoading = false) {
|
||||
if (!tpl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
tpl.loading.set(true);
|
||||
}
|
||||
|
||||
Meteor.call('getAttachmentStorageSettings', (error, settings) => {
|
||||
if (showLoading) {
|
||||
tpl.loading.set(false);
|
||||
}
|
||||
|
||||
if (error || !settings) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Failed to load attachment storage settings:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLimits = normalizeLimitSettings(settings);
|
||||
tpl.attachmentStorageSettings.set({
|
||||
...settings,
|
||||
limitSettings: normalizedLimits,
|
||||
});
|
||||
|
||||
tpl.attachmentLimitUnits.set({
|
||||
attachmentsUploadMaxBytes: pickUnitForBytes(normalizedLimits.attachmentsUploadMaxBytes),
|
||||
attachmentsDownloadMaxBytes: pickUnitForBytes(normalizedLimits.attachmentsDownloadMaxBytes),
|
||||
apiUploadMaxBytes: pickUnitForBytes(normalizedLimits.apiUploadMaxBytes),
|
||||
apiDownloadMaxBytes: pickUnitForBytes(normalizedLimits.apiDownloadMaxBytes),
|
||||
});
|
||||
|
||||
tpl.attachmentLimitEnabled.set({
|
||||
attachmentsUploadMaxBytes: normalizedLimits.attachmentsUploadMaxBytes > 0,
|
||||
attachmentsDownloadMaxBytes: normalizedLimits.attachmentsDownloadMaxBytes > 0,
|
||||
apiUploadMaxBytes: normalizedLimits.apiUploadMaxBytes > 0,
|
||||
apiDownloadMaxBytes: normalizedLimits.apiDownloadMaxBytes > 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getLimitUnitOptions(selectedUnit) {
|
||||
return [
|
||||
{ value: 'gb', labelKey: 'attachment-limit-unit-gb', selected: selectedUnit === 'gb' },
|
||||
{ value: 'mb', labelKey: 'attachment-limit-unit-mb', selected: selectedUnit === 'mb' },
|
||||
{ value: 'bytes', labelKey: 'attachment-limit-unit-bytes', selected: selectedUnit === 'bytes' },
|
||||
];
|
||||
}
|
||||
|
||||
Template.setting.onCreated(function () {
|
||||
this.error = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
@@ -131,6 +253,19 @@ Template.setting.onCreated(function () {
|
||||
this.layoutSetting = new ReactiveVar(false);
|
||||
this.webhookSetting = new ReactiveVar(false);
|
||||
this.attachmentSettings = new ReactiveVar(false);
|
||||
this.attachmentStorageSettings = new ReactiveVar(null);
|
||||
this.attachmentLimitUnits = new ReactiveVar({
|
||||
attachmentsUploadMaxBytes: 'mb',
|
||||
attachmentsDownloadMaxBytes: 'mb',
|
||||
apiUploadMaxBytes: 'mb',
|
||||
apiDownloadMaxBytes: 'mb',
|
||||
});
|
||||
this.attachmentLimitEnabled = new ReactiveVar({
|
||||
attachmentsUploadMaxBytes: false,
|
||||
attachmentsDownloadMaxBytes: false,
|
||||
apiUploadMaxBytes: false,
|
||||
apiDownloadMaxBytes: false,
|
||||
});
|
||||
// this.cronSettings = new ReactiveVar(false);
|
||||
// this.migrationErrorsList = new ReactiveVar([]);
|
||||
|
||||
@@ -142,6 +277,7 @@ Template.setting.onCreated(function () {
|
||||
Meteor.subscribe('accessibilitySettings');
|
||||
Meteor.subscribe('globalwebhooks');
|
||||
Meteor.subscribe('lockoutSettings');
|
||||
Meteor.subscribe('attachmentStorageSettings');
|
||||
|
||||
// Poll for migration errors
|
||||
// this.errorPollInterval = Meteor.setInterval(() => {
|
||||
@@ -161,6 +297,52 @@ Template.setting.onDestroyed(function () {
|
||||
// }
|
||||
});
|
||||
|
||||
Template.setting.onRendered(function () {
|
||||
this.previousAttachmentLimitEnabled = null;
|
||||
|
||||
this.autorun(() => {
|
||||
const enabledMap = this.attachmentLimitEnabled?.get() || {};
|
||||
const previousMap = this.previousAttachmentLimitEnabled || {};
|
||||
const fields = [
|
||||
'attachmentsUploadMaxBytes',
|
||||
'attachmentsDownloadMaxBytes',
|
||||
'apiUploadMaxBytes',
|
||||
'apiDownloadMaxBytes',
|
||||
];
|
||||
|
||||
fields.forEach((fieldName) => {
|
||||
const row = this.$(`.attachment-limit-row[data-field="${fieldName}"]`);
|
||||
if (!row.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnabled = enabledMap[fieldName] === true;
|
||||
const wasEnabled = previousMap[fieldName];
|
||||
|
||||
if (typeof wasEnabled === 'undefined') {
|
||||
if (isEnabled) {
|
||||
row.show();
|
||||
} else {
|
||||
row.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnabled === wasEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnabled) {
|
||||
row.stop(true, true).hide().slideDown();
|
||||
} else {
|
||||
row.stop(true, true).slideUp();
|
||||
}
|
||||
});
|
||||
|
||||
this.previousAttachmentLimitEnabled = { ...enabledMap };
|
||||
});
|
||||
});
|
||||
|
||||
Template.setting.helpers({
|
||||
isGeneralSetting() {
|
||||
const inst = Template.instance();
|
||||
@@ -252,6 +434,28 @@ Template.setting.helpers({
|
||||
return process.env.S3_PORT || 443;
|
||||
},
|
||||
|
||||
attachmentTransferLimitValue(fieldName) {
|
||||
const tpl = Template.instance();
|
||||
const settingsDoc = tpl.attachmentStorageSettings.get();
|
||||
const units = tpl.attachmentLimitUnits.get() || {};
|
||||
const limits = normalizeLimitSettings(settingsDoc);
|
||||
const unit = units[fieldName] || 'bytes';
|
||||
return toDisplayValue(limits[fieldName], unit);
|
||||
},
|
||||
|
||||
attachmentTransferLimitUnitOptions(fieldName) {
|
||||
const tpl = Template.instance();
|
||||
const units = tpl.attachmentLimitUnits.get() || {};
|
||||
const selectedUnit = units[fieldName] || 'bytes';
|
||||
return getLimitUnitOptions(selectedUnit);
|
||||
},
|
||||
|
||||
isAttachmentLimitEnabled(fieldName) {
|
||||
const tpl = Template.instance();
|
||||
const enabledMap = tpl.attachmentLimitEnabled.get() || {};
|
||||
return enabledMap[fieldName] === true;
|
||||
},
|
||||
|
||||
// Cron settings helpers
|
||||
// migrationStatus() {
|
||||
// return cronMigrationStatus.get() || TAPi18n.__('idle');
|
||||
@@ -466,6 +670,7 @@ Template.setting.events({
|
||||
tpl.webhookSetting.set(true);
|
||||
} else if (targetID === 'attachment-settings') {
|
||||
tpl.attachmentSettings.set(true);
|
||||
refreshAttachmentStorageSettings(tpl, true);
|
||||
// Set default sub-menu state for attachment settings
|
||||
console.log('Initializing attachment sub-menu');
|
||||
} // else if (targetID === 'cron-settings') {
|
||||
@@ -800,6 +1005,93 @@ Template.setting.events({
|
||||
});
|
||||
},
|
||||
|
||||
'change select.js-attachment-limit-unit'(event, tpl) {
|
||||
const fieldName = event.currentTarget.dataset.field;
|
||||
const selectedUnit = event.currentTarget.value;
|
||||
if (!fieldName || !selectedUnit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = tpl.attachmentLimitUnits.get() || {};
|
||||
tpl.attachmentLimitUnits.set({
|
||||
...current,
|
||||
[fieldName]: selectedUnit,
|
||||
});
|
||||
},
|
||||
|
||||
'click a.js-toggle-attachment-limit'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const fieldName = event.currentTarget.dataset.field;
|
||||
if (!fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = tpl.attachmentLimitEnabled.get() || {};
|
||||
tpl.attachmentLimitEnabled.set({
|
||||
...current,
|
||||
[fieldName]: !current[fieldName],
|
||||
});
|
||||
},
|
||||
|
||||
'click button.js-save-attachment-transfer-limits'(event, tpl) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentSettings = tpl.attachmentStorageSettings.get();
|
||||
if (!currentSettings) {
|
||||
alert(TAPi18n.__('attachment-transfer-limits-save-failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUnits = tpl.attachmentLimitUnits.get() || {};
|
||||
const enabledMap = tpl.attachmentLimitEnabled.get() || {};
|
||||
const fieldConfig = [
|
||||
{ fieldName: 'attachmentsUploadMaxBytes', inputId: '#attachments-upload-limit-value' },
|
||||
{ fieldName: 'attachmentsDownloadMaxBytes', inputId: '#attachments-download-limit-value' },
|
||||
{ fieldName: 'apiUploadMaxBytes', inputId: '#api-upload-limit-value' },
|
||||
{ fieldName: 'apiDownloadMaxBytes', inputId: '#api-download-limit-value' },
|
||||
];
|
||||
|
||||
const nextLimitSettings = {};
|
||||
for (const field of fieldConfig) {
|
||||
if (!enabledMap[field.fieldName]) {
|
||||
nextLimitSettings[field.fieldName] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const unit = currentUnits[field.fieldName] || 'bytes';
|
||||
const value = $(field.inputId).val();
|
||||
const bytesValue = toBytes(value, unit);
|
||||
if (bytesValue === null || bytesValue <= 0) {
|
||||
alert(TAPi18n.__('attachment-transfer-limits-invalid-value'));
|
||||
return;
|
||||
}
|
||||
nextLimitSettings[field.fieldName] = bytesValue;
|
||||
}
|
||||
|
||||
const nextSettings = {
|
||||
...currentSettings,
|
||||
uploadSettings: {
|
||||
...(currentSettings.uploadSettings || {}),
|
||||
// Keep legacy field in sync for backward compatibility.
|
||||
maxFileSize: nextLimitSettings.attachmentsUploadMaxBytes,
|
||||
},
|
||||
limitSettings: {
|
||||
...(currentSettings.limitSettings || {}),
|
||||
...nextLimitSettings,
|
||||
},
|
||||
};
|
||||
|
||||
Meteor.call('updateAttachmentStorageSettings', nextSettings, (error) => {
|
||||
if (error) {
|
||||
alert(`${TAPi18n.__('attachment-transfer-limits-save-failed')}: ${error.reason || error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
alert(TAPi18n.__('attachment-transfer-limits-saved'));
|
||||
refreshAttachmentStorageSettings(tpl, false);
|
||||
});
|
||||
},
|
||||
|
||||
// Event handlers for cron settings
|
||||
// 'click button.js-start-migration'(event, tpl) {
|
||||
// event.preventDefault();
|
||||
|
||||
@@ -82,7 +82,7 @@ template(name="membersWidget")
|
||||
if currentUser.isBoardMember
|
||||
a.member.add-member.sandstorm-powerbox-request-identity(title="{{_ 'add-members'}}")
|
||||
i.fa.fa-plus
|
||||
else if currentUser.isBoardAdmin
|
||||
else if currentUser.canInviteToBoard
|
||||
a.member.add-member.js-manage-board-members(title="{{_ 'add-members'}}")
|
||||
i.fa.fa-plus
|
||||
.clearfix
|
||||
@@ -368,16 +368,6 @@ template(name="boardCardSettingsPopup")
|
||||
i.fa.fa-file-text-o
|
||||
| {{_ 'description'}}
|
||||
| {{_ 'custom-field-text'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
span
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-description-text-on-minicard(title="{{_ 'description-on-minicard'}}" class="{{#if allowsDescriptionTextOnMinicard}}is-checked{{/if}}")
|
||||
i.fa(class="{{#if allowsDescriptionTextOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
|
||||
.card-settings-column
|
||||
span
|
||||
i.fa.fa-file-text-o
|
||||
| {{_ 'description-on-minicard'}}
|
||||
.card-settings-row
|
||||
.card-settings-column
|
||||
a.flex.js-field-has-checklists(title="{{_ 'checklists'}}" class="{{#if allowsChecklists}}is-checked{{/if}}")
|
||||
@@ -504,7 +494,7 @@ template(name="boardSubtaskSettingsPopup")
|
||||
else
|
||||
option(value=_id) {{title}}
|
||||
|
||||
template(name="chooseBoardSource")
|
||||
template(name="chooseBoardSourcePopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
a(href="{{pathFor '/import/trello'}}") {{_ 'from-trello'}}
|
||||
|
||||
@@ -24,6 +24,17 @@ const defaultView = 'home';
|
||||
const MCB = '.materialCheckBox';
|
||||
const CKCLS = 'is-checked';
|
||||
|
||||
function getMinicardSetting(board, onMinicardField, cardField, defaultValue) {
|
||||
if (!board) return false;
|
||||
if (board[onMinicardField] !== null && board[onMinicardField] !== undefined) {
|
||||
return board[onMinicardField];
|
||||
}
|
||||
if (cardField && board[cardField] !== null && board[cardField] !== undefined) {
|
||||
return board[cardField];
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const viewTitles = {
|
||||
filter: 'filter-cards',
|
||||
search: 'search-cards',
|
||||
@@ -185,13 +196,20 @@ Template.sidebar.events({
|
||||
FlowRouter.go('shortcuts');
|
||||
},
|
||||
'click .js-keyboard-shortcuts-toggle'() {
|
||||
ReactiveCache.getCurrentUser().toggleKeyboardShortcuts();
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) user.toggleKeyboardShortcuts();
|
||||
},
|
||||
'click .js-vertical-scrollbars-toggle'() {
|
||||
ReactiveCache.getCurrentUser().toggleVerticalScrollbars();
|
||||
},
|
||||
'click .js-show-week-of-year-toggle'() {
|
||||
ReactiveCache.getCurrentUser().toggleShowWeekOfYear();
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) {
|
||||
user.toggleShowWeekOfYear();
|
||||
} else {
|
||||
const current = window.localStorage.getItem('showWeekOfYear') === 'true';
|
||||
window.localStorage.setItem('showWeekOfYear', String(!current));
|
||||
}
|
||||
},
|
||||
'click .sidebar-accessibility'(event, tpl) {
|
||||
FlowRouter.go('accessibility');
|
||||
@@ -226,7 +244,8 @@ Template.homeSidebar.helpers({
|
||||
},
|
||||
isShowWeekOfYear() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return user && user.isShowWeekOfYear();
|
||||
if (!user) return window.localStorage.getItem('showWeekOfYear') === 'true';
|
||||
return user.isShowWeekOfYear();
|
||||
},
|
||||
showActivities() {
|
||||
let ret = Utils.getCurrentBoard().showActivities ?? false;
|
||||
@@ -371,10 +390,12 @@ Template.boardMenuPopup.events({
|
||||
}),
|
||||
'click .js-archive-board ': Popup.afterConfirm('archiveBoard', async function() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
await currentBoard.archive();
|
||||
// XXX We should have some kind of notification on top of the page to
|
||||
// confirm that the board was successfully archived.
|
||||
FlowRouter.go('home');
|
||||
try {
|
||||
await Meteor.callAsync('archiveBoard', currentBoard._id);
|
||||
FlowRouter.go('home');
|
||||
} catch (err) {
|
||||
alert(err?.reason || err?.message || 'Failed to archive board');
|
||||
}
|
||||
}),
|
||||
'click .js-delete-board': Popup.afterConfirm('deleteBoard', function() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
@@ -1032,7 +1053,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsReceivedDateOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsReceivedDateOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsReceivedDateOnMinicard', 'allowsReceivedDate', true);
|
||||
},
|
||||
allowsStartDate() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1042,7 +1063,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsStartDateOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsStartDateOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsStartDateOnMinicard', 'allowsStartDate', true);
|
||||
},
|
||||
allowsDueDate() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1052,7 +1073,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsDueDateOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsDueDateOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsDueDateOnMinicard', 'allowsDueDate', true);
|
||||
},
|
||||
allowsEndDate() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1062,7 +1083,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsEndDateOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsEndDateOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsEndDateOnMinicard', 'allowsEndDate', true);
|
||||
},
|
||||
allowsSubtasks() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1072,7 +1093,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsSubtasksOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsSubtasksOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsSubtasksOnMinicard', 'allowsSubtasks', true);
|
||||
},
|
||||
allowsCreator() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1082,7 +1103,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsCreatorOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? (currentBoard.allowsCreatorOnMinicard ?? false) : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsCreatorOnMinicard', 'allowsCreator', false);
|
||||
},
|
||||
allowsMembers() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1092,7 +1113,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsMembersOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsMembersOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsMembersOnMinicard', 'allowsMembers', true);
|
||||
},
|
||||
allowsAssignee() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1102,7 +1123,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsAssigneeOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsAssigneeOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsAssigneeOnMinicard', 'allowsAssignee', true);
|
||||
},
|
||||
allowsAssignedBy() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1112,7 +1133,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsAssignedByOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsAssignedByOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsAssignedByOnMinicard', 'allowsAssignedBy', true);
|
||||
},
|
||||
allowsRequestedBy() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1122,7 +1143,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsRequestedByOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsRequestedByOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsRequestedByOnMinicard', 'allowsRequestedBy', true);
|
||||
},
|
||||
allowsCardSortingByNumber() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1142,12 +1163,12 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsLabelsOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsLabelsOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsLabelsOnMinicard', 'allowsLabels', true);
|
||||
},
|
||||
allowsShowListsOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsShowListsOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsShowListsOnMinicard', 'allowsShowLists', false);
|
||||
},
|
||||
allowsChecklists() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1157,7 +1178,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsChecklistsOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsChecklistsOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsChecklistsOnMinicard', 'allowsChecklists', true);
|
||||
},
|
||||
allowsAttachments() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1167,7 +1188,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsAttachmentsOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsAttachmentsOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsAttachmentsOnMinicard', 'allowsAttachments', true);
|
||||
},
|
||||
allowsComments() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1182,7 +1203,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsCardNumberOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsCardNumberOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsCardNumberOnMinicard', 'allowsCardNumber', false);
|
||||
},
|
||||
allowsDescriptionTitle() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1192,7 +1213,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsDescriptionTitleOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsDescriptionTitleOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsDescriptionTitleOnMinicard', 'allowsDescriptionTitle', true);
|
||||
},
|
||||
allowsDescriptionText() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1202,7 +1223,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
isBoardSelected() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.dateSettingsDefaultBoardID : false;
|
||||
return currentBoard ? currentBoard.dateSettingsDefaultBoardId : false;
|
||||
},
|
||||
isNullBoardSelected() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1215,7 +1236,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsDescriptionTextOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsDescriptionTextOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsDescriptionTextOnMinicard', 'allowsDescriptionText', false);
|
||||
},
|
||||
allowsCoverAttachmentOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
@@ -1230,7 +1251,7 @@ Template.boardCardSettingsPopup.helpers({
|
||||
allowsCardSortingByNumberOnMinicard() {
|
||||
const boardId = Session.get('currentBoard');
|
||||
const currentBoard = ReactiveCache.getBoard(boardId);
|
||||
return currentBoard ? currentBoard.allowsCardSortingByNumberOnMinicard : false;
|
||||
return getMinicardSetting(currentBoard, 'allowsCardSortingByNumberOnMinicard', 'allowsCardSortingByNumber', false);
|
||||
},
|
||||
boards() {
|
||||
const ret = ReactiveCache.getBoards(
|
||||
@@ -1400,35 +1421,13 @@ Template.boardCardSettingsPopup.events({
|
||||
},
|
||||
'click .js-field-has-card-show-lists-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsShowListsOnMinicard = !tpl.currentBoard
|
||||
.allowsShowListsOnMinicard;
|
||||
tpl.currentBoard.setAllowsShowListsOnMinicard(
|
||||
tpl.currentBoard.allowsShowListsOnMinicard,
|
||||
);
|
||||
$(`.js-field-has-card-show-lists-on-minicard ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsShowListsOnMinicard,
|
||||
);
|
||||
$('.js-field-has-card-show-lists-on-minicard').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsShowListsOnMinicard,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsShowListsOnMinicard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsShowListsOnMinicard: newValue } });
|
||||
},
|
||||
'click .js-field-has-description-title'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsDescriptionTitle = !tpl.currentBoard
|
||||
.allowsDescriptionTitle;
|
||||
tpl.currentBoard.setAllowsDescriptionTitle(
|
||||
tpl.currentBoard.allowsDescriptionTitle,
|
||||
);
|
||||
$(`.js-field-has-description-title ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsDescriptionTitle,
|
||||
);
|
||||
$('.js-field-has-description-title').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsDescriptionTitle,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsDescriptionTitle;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsDescriptionTitle: newValue } });
|
||||
},
|
||||
'click .js-field-has-description-title-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
@@ -1437,19 +1436,8 @@ Template.boardCardSettingsPopup.events({
|
||||
},
|
||||
'click .js-field-has-card-number'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsCardNumber = !tpl.currentBoard
|
||||
.allowsCardNumber;
|
||||
tpl.currentBoard.setAllowsCardNumber(
|
||||
tpl.currentBoard.allowsCardNumber,
|
||||
);
|
||||
$(`.js-field-has-card-number ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsCardNumber,
|
||||
);
|
||||
$('.js-field-has-card-number').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsCardNumber,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsCardNumber;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsCardNumber: newValue } });
|
||||
},
|
||||
'click .js-field-has-card-number-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
@@ -1458,51 +1446,18 @@ Template.boardCardSettingsPopup.events({
|
||||
},
|
||||
'click .js-field-has-description-text-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsDescriptionTextOnMinicard = !tpl.currentBoard
|
||||
.allowsDescriptionTextOnMinicard;
|
||||
tpl.currentBoard.setallowsDescriptionTextOnMinicard(
|
||||
tpl.currentBoard.allowsDescriptionTextOnMinicard,
|
||||
);
|
||||
$(`.js-field-has-description-text-on-minicard ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsDescriptionTextOnMinicard,
|
||||
);
|
||||
$('.js-field-has-description-text-on-minicard').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsDescriptionTextOnMinicard,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsDescriptionTextOnMinicard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsDescriptionTextOnMinicard: newValue } });
|
||||
},
|
||||
'click .js-field-has-description-text'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsDescriptionText = !tpl.currentBoard
|
||||
.allowsDescriptionText;
|
||||
tpl.currentBoard.setAllowsDescriptionText(
|
||||
tpl.currentBoard.allowsDescriptionText,
|
||||
);
|
||||
$(`.js-field-has-description-text ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsDescriptionText,
|
||||
);
|
||||
$('.js-field-has-description-text').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsDescriptionText,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsDescriptionText;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsDescriptionText: newValue } });
|
||||
},
|
||||
'click .js-field-has-checklists'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsChecklists = !tpl.currentBoard
|
||||
.allowsChecklists;
|
||||
tpl.currentBoard.setAllowsChecklists(
|
||||
tpl.currentBoard.allowsChecklists,
|
||||
);
|
||||
$(`.js-field-has-checklists ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsChecklists,
|
||||
);
|
||||
$('.js-field-has-checklists').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsChecklists,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsChecklists;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsChecklists: newValue } });
|
||||
},
|
||||
'click .js-field-has-checklists-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
@@ -1511,19 +1466,8 @@ Template.boardCardSettingsPopup.events({
|
||||
},
|
||||
'click .js-field-has-attachments'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsAttachments = !tpl.currentBoard
|
||||
.allowsAttachments;
|
||||
tpl.currentBoard.setAllowsAttachments(
|
||||
tpl.currentBoard.allowsAttachments,
|
||||
);
|
||||
$(`.js-field-has-attachments ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsAttachments,
|
||||
);
|
||||
$('.js-field-has-attachments').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsAttachments,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsAttachments;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsAttachments: newValue } });
|
||||
},
|
||||
'click .js-field-has-attachments-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
@@ -1532,80 +1476,28 @@ Template.boardCardSettingsPopup.events({
|
||||
},
|
||||
'click .js-field-has-comments'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsComments = !tpl.currentBoard.allowsComments;
|
||||
tpl.currentBoard.setAllowsComments(tpl.currentBoard.allowsComments);
|
||||
$(`.js-field-has-comments ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsComments,
|
||||
);
|
||||
$('.js-field-has-comments').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsComments,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsComments;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsComments: newValue } });
|
||||
},
|
||||
'click .js-field-has-activities'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsActivities = !tpl.currentBoard
|
||||
.allowsActivities;
|
||||
tpl.currentBoard.setAllowsActivities(
|
||||
tpl.currentBoard.allowsActivities,
|
||||
);
|
||||
$(`.js-field-has-activities ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsActivities,
|
||||
);
|
||||
$('.js-field-has-activities').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsActivities,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsActivities;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsActivities: newValue } });
|
||||
},
|
||||
'click .js-field-has-cover-attachment-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsCoverAttachmentOnMinicard = !tpl.currentBoard
|
||||
.allowsCoverAttachmentOnMinicard;
|
||||
tpl.currentBoard.setallowsCoverAttachmentOnMinicard(
|
||||
tpl.currentBoard.allowsCoverAttachmentOnMinicard,
|
||||
);
|
||||
$(`.js-field-has-cover-attachment-on-minicard ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsCoverAttachmentOnMinicard,
|
||||
);
|
||||
$('.js-field-has-cover-attachment-on-minicard').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsCoverAttachmentOnMinicard,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsCoverAttachmentOnMinicard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsCoverAttachmentOnMinicard: newValue } });
|
||||
},
|
||||
'click .js-field-has-badge-attachment-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsBadgeAttachmentOnMinicard = !tpl.currentBoard
|
||||
.allowsBadgeAttachmentOnMinicard;
|
||||
tpl.currentBoard.setallowsBadgeAttachmentOnMinicard(
|
||||
tpl.currentBoard.allowsBadgeAttachmentOnMinicard,
|
||||
);
|
||||
$(`.js-field-has-badge-attachment-on-minicard ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsBadgeAttachmentOnMinicard,
|
||||
);
|
||||
$('.js-field-has-badge-attachment-on-minicard').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsBadgeAttachmentOnMinicard,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsBadgeAttachmentOnMinicard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsBadgeAttachmentOnMinicard: newValue } });
|
||||
},
|
||||
'click .js-field-has-card-sorting-by-number-on-minicard'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.currentBoard.allowsCardSortingByNumberOnMinicard = !tpl.currentBoard
|
||||
.allowsCardSortingByNumberOnMinicard;
|
||||
tpl.currentBoard.setallowsCardSortingByNumberOnMinicard(
|
||||
tpl.currentBoard.allowsCardSortingByNumberOnMinicard,
|
||||
);
|
||||
$(`.js-field-has-card-sorting-by-number-on-minicard ${MCB}`).toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsCardSortingByNumberOnMinicard,
|
||||
);
|
||||
$('.js-field-has-card-sorting-by-number-on-minicard').toggleClass(
|
||||
CKCLS,
|
||||
tpl.currentBoard.allowsCardSortingByNumberOnMinicard,
|
||||
);
|
||||
const newValue = !tpl.currentBoard.allowsCardSortingByNumberOnMinicard;
|
||||
Boards.update(tpl.currentBoard._id, { $set: { allowsCardSortingByNumberOnMinicard: newValue } });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,14 +6,15 @@ template(name="archivesSidebar")
|
||||
li.tab-item(class="{{isArchiveTabActive slug}} {{class}}" data-tab="{{slug}}") {{name}}
|
||||
.tabs-content-container
|
||||
section.tabs-content(class="{{isArchiveTabActive 'cards'}}" data-tab="cards")
|
||||
unless isWorker
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
p.quiet
|
||||
a.js-restore-all-cards {{_ 'restore-all'}}
|
||||
if currentUser.isBoardAdmin
|
||||
| -
|
||||
a.js-delete-all-cards {{_ 'delete-all'}}
|
||||
if currentUser
|
||||
unless isWorker
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
p.quiet
|
||||
a.js-restore-all-cards {{_ 'restore-all'}}
|
||||
if currentUser.isBoardAdmin
|
||||
| -
|
||||
a.js-delete-all-cards {{_ 'delete-all'}}
|
||||
each archivedCards
|
||||
.minicard-wrapper.js-minicard
|
||||
+minicard(this)
|
||||
@@ -36,18 +37,19 @@ template(name="archivesSidebar")
|
||||
p.no-items-message {{_ 'no-archived-cards'}}
|
||||
|
||||
section.tabs-content(class="{{isArchiveTabActive 'lists'}}" data-tab="lists")
|
||||
unless isWorker
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
p.quiet
|
||||
a.js-restore-all-lists {{_ 'restore-all'}}
|
||||
if currentUser.isBoardAdmin
|
||||
| -
|
||||
a.js-delete-all-lists {{_ 'delete-all'}}
|
||||
if currentUser
|
||||
unless isWorker
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
p.quiet
|
||||
a.js-restore-all-lists {{_ 'restore-all'}}
|
||||
if currentUser.isBoardAdmin
|
||||
| -
|
||||
a.js-delete-all-lists {{_ 'delete-all'}}
|
||||
ul.archived-lists
|
||||
each archivedLists
|
||||
li.archived-lists-item
|
||||
= title
|
||||
span.archived-item-title {{title}}
|
||||
if currentUser.isBoardMember
|
||||
unless isWorker
|
||||
unless currentUser.isReadOnly
|
||||
@@ -65,18 +67,19 @@ template(name="archivesSidebar")
|
||||
li.no-items-message {{_ 'no-archived-lists'}}
|
||||
|
||||
section.tabs-content(class="{{isArchiveTabActive 'swimlanes'}}" data-tab="swimlanes")
|
||||
unless isWorker
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
p.quiet
|
||||
a.js-restore-all-swimlanes {{_ 'restore-all'}}
|
||||
if currentUser.isBoardAdmin
|
||||
| -
|
||||
a.js-delete-all-swimlanes {{_ 'delete-all'}}
|
||||
if currentUser
|
||||
unless isWorker
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
p.quiet
|
||||
a.js-restore-all-swimlanes {{_ 'restore-all'}}
|
||||
if currentUser.isBoardAdmin
|
||||
| -
|
||||
a.js-delete-all-swimlanes {{_ 'delete-all'}}
|
||||
ul.archived-lists
|
||||
each archivedSwimlanes
|
||||
li.archived-lists-item
|
||||
= title
|
||||
span.archived-item-title {{title}}
|
||||
if currentUser.isBoardMember
|
||||
unless isWorker
|
||||
unless currentUser.isReadOnly
|
||||
|
||||
@@ -151,13 +151,14 @@ Template.archivesSidebar.helpers({
|
||||
return Template.instance().isArchiveReady;
|
||||
},
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return user && user.isBoardAdmin();
|
||||
},
|
||||
isWorker() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
return (
|
||||
!currentBoard.hasAdmin(this.userId) && currentBoard.hasWorker(this.userId)
|
||||
);
|
||||
if (!currentBoard) return false;
|
||||
const userId = Meteor.userId();
|
||||
return !currentBoard.hasAdmin(userId) && currentBoard.hasWorker(userId);
|
||||
},
|
||||
|
||||
tabs() {
|
||||
|
||||
@@ -21,14 +21,14 @@ template(name="filterSidebar")
|
||||
| {{_ 'filter-labels-label'}}
|
||||
ul.sidebar-list
|
||||
li(class="{{#if Filter.labelIds.isSelected undefined}}active{{/if}}")
|
||||
a.name.js-toggle-label-filter
|
||||
a.name.js-toggle-label-filter(data-filter-id="__none__")
|
||||
span.sidebar-list-item-description
|
||||
| {{_ 'filter-no-label'}}
|
||||
if Filter.labelIds.isSelected undefined
|
||||
i.fa.fa-check
|
||||
each currentBoard.labels
|
||||
li
|
||||
a.name.js-toggle-label-filter
|
||||
a.name.js-toggle-label-filter(data-filter-id="{{_id}}")
|
||||
span.card-label.square(class="card-label-{{color}}")
|
||||
span.sidebar-list-item-description
|
||||
if name
|
||||
@@ -43,7 +43,7 @@ template(name="filterSidebar")
|
||||
| {{_ 'filter-member-label'}}
|
||||
ul.sidebar-list
|
||||
li(class="{{#if Filter.members.isSelected undefined}}active{{/if}}")
|
||||
a.name.js-toggle-member-filter
|
||||
a.name.js-toggle-member-filter(data-filter-id="__none__")
|
||||
span.sidebar-list-item-description
|
||||
| {{_ 'filter-no-member'}}
|
||||
if Filter.members.isSelected undefined
|
||||
@@ -51,7 +51,7 @@ template(name="filterSidebar")
|
||||
each currentBoard.activeMembers
|
||||
with getUser userId
|
||||
li(class="{{#if Filter.members.isSelected _id}}active{{/if}}")
|
||||
a.name.js-toggle-member-filter
|
||||
a.name.js-toggle-member-filter(data-filter-id="{{_id}}")
|
||||
+userAvatar(userId=this._id)
|
||||
span.sidebar-list-item-description
|
||||
= profile.fullname
|
||||
@@ -64,7 +64,7 @@ template(name="filterSidebar")
|
||||
| {{_ 'filter-assignee-label'}}
|
||||
ul.sidebar-list
|
||||
li(class="{{#if Filter.assignees.isSelected undefined}}active{{/if}}")
|
||||
a.name.js-toggle-assignee-filter
|
||||
a.name.js-toggle-assignee-filter(data-filter-id="__none__")
|
||||
span.sidebar-list-item-description
|
||||
| {{_ 'filter-no-assignee'}}
|
||||
if Filter.assignees.isSelected undefined
|
||||
@@ -72,7 +72,7 @@ template(name="filterSidebar")
|
||||
each currentBoard.activeMembers
|
||||
with getUser userId
|
||||
li(class="{{#if Filter.assignees.isSelected _id}}active{{/if}}")
|
||||
a.name.js-toggle-assignee-filter
|
||||
a.name.js-toggle-assignee-filter(data-filter-id="{{_id}}")
|
||||
+userAvatar(userId=this._id)
|
||||
span.sidebar-list-item-description
|
||||
= profile.fullname
|
||||
@@ -126,14 +126,14 @@ template(name="filterSidebar")
|
||||
| {{_ 'filter-custom-fields-label'}}
|
||||
ul.sidebar-list
|
||||
li(class="{{#if Filter.customFields.isSelected undefined}}active{{/if}}")
|
||||
a.name.js-toggle-custom-fields-filter
|
||||
a.name.js-toggle-custom-fields-filter(data-filter-id="__none__")
|
||||
span.sidebar-list-item-description
|
||||
| {{_ 'filter-no-custom-fields'}}
|
||||
if Filter.customFields.isSelected undefined
|
||||
i.fa.fa-check
|
||||
each currentBoard.customFields
|
||||
li(class="{{#if Filter.customFields.isSelected _id}}active{{/if}}")
|
||||
a.name.js-toggle-custom-fields-filter
|
||||
a.name.js-toggle-custom-fields-filter(data-filter-id="{{_id}}")
|
||||
span.sidebar-list-item-description
|
||||
| {{ name }}
|
||||
if Filter.customFields.isSelected _id
|
||||
|
||||
@@ -7,6 +7,17 @@ import { Utils } from '/client/lib/utils';
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
|
||||
function getFilterIdFromEvent(evt, fallbackId) {
|
||||
const filterId = evt.currentTarget?.getAttribute('data-filter-id');
|
||||
if (filterId === '__none__') {
|
||||
return undefined;
|
||||
}
|
||||
if (filterId !== null) {
|
||||
return filterId;
|
||||
}
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
Template.filterSidebar.events({
|
||||
'submit .js-list-filter'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
@@ -19,17 +30,17 @@ Template.filterSidebar.events({
|
||||
},
|
||||
'click .js-toggle-label-filter'(evt) {
|
||||
evt.preventDefault();
|
||||
Filter.labelIds.toggle(Template.currentData()._id);
|
||||
Filter.labelIds.toggle(getFilterIdFromEvent(evt, this?._id));
|
||||
Filter.resetExceptions();
|
||||
},
|
||||
'click .js-toggle-member-filter'(evt) {
|
||||
evt.preventDefault();
|
||||
Filter.members.toggle(Template.currentData()._id);
|
||||
Filter.members.toggle(getFilterIdFromEvent(evt, this?._id));
|
||||
Filter.resetExceptions();
|
||||
},
|
||||
'click .js-toggle-assignee-filter'(evt) {
|
||||
evt.preventDefault();
|
||||
Filter.assignees.toggle(Template.currentData()._id);
|
||||
Filter.assignees.toggle(getFilterIdFromEvent(evt, this?._id));
|
||||
Filter.resetExceptions();
|
||||
},
|
||||
'click .js-toggle-no-due-date-filter'(evt) {
|
||||
@@ -81,7 +92,7 @@ Template.filterSidebar.events({
|
||||
},
|
||||
'click .js-toggle-custom-fields-filter'(evt) {
|
||||
evt.preventDefault();
|
||||
Filter.customFields.toggle(Template.currentData()._id);
|
||||
Filter.customFields.toggle(getFilterIdFromEvent(evt, this?._id));
|
||||
Filter.resetExceptions();
|
||||
},
|
||||
'change .js-field-advanced-filter'(evt, tpl) {
|
||||
|
||||
@@ -475,9 +475,19 @@ function initializeSwimlaneResize(tpl, retryCount = 0) {
|
||||
const minHeight = 100;
|
||||
const maxHeight = 2000;
|
||||
|
||||
// Read the vertical page coordinate from either a jQuery mouse event or a
|
||||
// native touch event (touchstart/move expose `touches`, touchend exposes
|
||||
// `changedTouches`).
|
||||
const getEventPageY = (e) => {
|
||||
const oe = e.originalEvent || e;
|
||||
if (oe.touches && oe.touches.length) return oe.touches[0].pageY;
|
||||
if (oe.changedTouches && oe.changedTouches.length) return oe.changedTouches[0].pageY;
|
||||
return e.pageY;
|
||||
};
|
||||
|
||||
const startResize = (e) => {
|
||||
isResizing = true;
|
||||
startY = e.pageY || e.originalEvent.touches[0].pageY;
|
||||
startY = getEventPageY(e);
|
||||
startHeight = parseInt($swimlane.css('height')) || 300;
|
||||
|
||||
$swimlane.addClass('swimlane-resizing');
|
||||
@@ -493,7 +503,7 @@ function initializeSwimlaneResize(tpl, retryCount = 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentY = e.pageY || e.originalEvent.touches[0].pageY;
|
||||
const currentY = getEventPageY(e);
|
||||
const deltaY = currentY - startY;
|
||||
const newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY));
|
||||
|
||||
@@ -517,7 +527,7 @@ function initializeSwimlaneResize(tpl, retryCount = 0) {
|
||||
isResizing = false;
|
||||
|
||||
// Calculate final height
|
||||
const currentY = e.pageY || e.originalEvent.touches[0].pageY;
|
||||
const currentY = getEventPageY(e);
|
||||
const deltaY = currentY - startY;
|
||||
const finalHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY));
|
||||
|
||||
@@ -582,10 +592,15 @@ function initializeSwimlaneResize(tpl, retryCount = 0) {
|
||||
$(document).on('mousemove', doResize);
|
||||
$(document).on('mouseup', stopResize);
|
||||
|
||||
// Touch events for mobile
|
||||
$resizeHandle.on('touchstart', startResize, { passive: false });
|
||||
$(document).on('touchmove', doResize, { passive: false });
|
||||
$(document).on('touchend', stopResize, { passive: false });
|
||||
// Touch events for mobile.
|
||||
// NOTE: jQuery's .on() does not accept an addEventListener-style options
|
||||
// object — passing { passive: false } made jQuery bind the object itself as
|
||||
// the handler, throwing "handler.apply is not a function" on every touch.
|
||||
// Use the native API so { passive: false } actually applies and
|
||||
// preventDefault() works during the resize.
|
||||
$resizeHandle[0].addEventListener('touchstart', startResize, { passive: false });
|
||||
document.addEventListener('touchmove', doResize, { passive: false });
|
||||
document.addEventListener('touchend', stopResize, { passive: false });
|
||||
|
||||
// Prevent dragscroll interference
|
||||
$resizeHandle.on('mousedown', (e) => {
|
||||
@@ -655,7 +670,9 @@ Template.swimlane.helpers({
|
||||
}
|
||||
}
|
||||
if (Filter.hideEmpty.isSelected()) {
|
||||
const cards = list.cards();
|
||||
// Pass the current swimlane ID so we only count cards belonging to
|
||||
// this swimlane (not cards in other swimlanes that happen to match).
|
||||
const cards = list.cards(this._id);
|
||||
if (cards.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ template(name='passwordInput')
|
||||
label(for='at-field-{{_id}}') {{displayName}}
|
||||
.password-input-container
|
||||
input.password-field(type="{{type}}" placeholder="{{displayName}}" autocomplete="{{autocomplete}}" required="{{required}}")
|
||||
button.password-toggle-btn(type="button" tabindex="-1" aria-label="Toggle password visibility" title="Toggle password visibility")
|
||||
button.password-toggle-btn.primary(type="button" tabindex="-1" aria-label="Toggle password visibility" title="Toggle password visibility")
|
||||
i.fa.fa-eye.eye-icon
|
||||
i.fa.fa-eye-slash.eye-slash-icon
|
||||
if errs
|
||||
|
||||
@@ -25,65 +25,57 @@
|
||||
width: 100%;
|
||||
}
|
||||
.password-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
align-items: stretch;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
.password-input-container input {
|
||||
flex: 1;
|
||||
padding-right: 55px; /* More room for the bigger button */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.password-toggle-btn {
|
||||
position: absolute;
|
||||
right: 5px; /* Adjusted for larger button */
|
||||
top: calc(50% - 26px); /* Moved up by 20px + 6px = 26px total */
|
||||
transform: translateY(-50%);
|
||||
background: #f8f8f8 !important;
|
||||
border: 1px solid #ddd !important;
|
||||
border-radius: 3px !important;
|
||||
color: #000 !important; /* Black color for the icon */
|
||||
cursor: pointer;
|
||||
padding: 8px 6px 8px 12px; /* 2x bigger padding, 6px less on right */
|
||||
font-size: 16px; /* 2x bigger font size */
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
line-height: 1;
|
||||
display: flex !important;
|
||||
/* The show/hide password button is a square: its width and height match the
|
||||
password input field height, and the field flex-grows to fill the space the
|
||||
narrower button frees up on the right. */
|
||||
.password-input-container .password-field {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-layout .auth-dialog .at-form .password-input-container .password-toggle-btn {
|
||||
position: static;
|
||||
transform: none;
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
min-width: 40px; /* 2x bigger minimum width */
|
||||
min-height: 32px; /* 2x bigger minimum height */
|
||||
}
|
||||
/* Adjust position for login and register pages */
|
||||
.auth-layout .password-toggle-btn {
|
||||
top: calc(50% - 11px); /* Move 15px down for login/register */
|
||||
}
|
||||
.password-toggle-btn .eye-text {
|
||||
color: #000 !important;
|
||||
font-size: 16px !important;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
filter: grayscale(100%);
|
||||
-webkit-filter: grayscale(100%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.eye-slash-line {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
stroke: #000;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
.auth-layout .auth-dialog .at-form .password-input-container .password-toggle-btn .fa {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.password-toggle-btn:hover .eye-text {
|
||||
color: #000 !important;
|
||||
filter: grayscale(100%);
|
||||
-webkit-filter: grayscale(100%);
|
||||
opacity: 0.8;
|
||||
.auth-layout .auth-dialog .at-form .password-input-container .password-field {
|
||||
padding-right: 0;
|
||||
}
|
||||
body.mobile-mode .password-input-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
body.mobile-mode .auth-layout .auth-dialog .at-form .password-input-container .password-toggle-btn {
|
||||
aspect-ratio: auto;
|
||||
width: 100%;
|
||||
}
|
||||
.auth-layout .auth-dialog .at-form button {
|
||||
width: 100%;
|
||||
|
||||
@@ -123,47 +123,14 @@ export class CardSearchPaged {
|
||||
this.sessionData = this.getSessionData();
|
||||
const cards = [];
|
||||
|
||||
if (this.sessionData && this.sessionData.cards) {
|
||||
this.sessionData.cards.forEach(cardId => {
|
||||
const card = ReactiveCache.getCard(cardId);
|
||||
if (card && card._id) {
|
||||
cards.push(card);
|
||||
}
|
||||
if (this.sessionData && this.sessionData.cards && this.sessionData.cards.length > 0) {
|
||||
Cards.find({ _id: { $in: this.sessionData.cards } }).forEach(card => {
|
||||
if (card && card._id) cards.push(card);
|
||||
});
|
||||
|
||||
// Fallback: if no cards found, try fetching directly from Cards collection
|
||||
if (cards.length === 0 && this.sessionData.cards && this.sessionData.cards.length > 0) {
|
||||
if (directCards && directCards.length > 0) {
|
||||
directCards.forEach(card => {
|
||||
if (card && card._id) {
|
||||
cards.push(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.queryErrors = this.sessionData.errors || [];
|
||||
} else if (this.sessionData) {
|
||||
this.queryErrors = this.sessionData.errors || [];
|
||||
} else {
|
||||
// Fallback: try to get cards directly from the client-side collection
|
||||
// Use a more efficient query with limit and sort
|
||||
const selector = {
|
||||
type: 'cardType-card',
|
||||
dueAt: { $exists: true, $nin: [null, ''] }
|
||||
};
|
||||
const options = {
|
||||
sort: { dueAt: 1 }, // Sort by due date ascending (oldest first)
|
||||
limit: 100 // Limit to 100 cards for performance
|
||||
};
|
||||
const allCards = Cards.find(selector, options).fetch();
|
||||
|
||||
if (allCards && allCards.length > 0) {
|
||||
allCards.forEach(card => {
|
||||
if (card && card._id) {
|
||||
cards.push(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.queryErrors = [];
|
||||
}
|
||||
if (this.queryErrors.length) {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
const hotkeys = require('hotkeys-js').default;
|
||||
// Late-bind Sidebar to avoid circular dependency (sidebar.js needs its template first)
|
||||
let _Sidebar;
|
||||
function getSidebar() {
|
||||
if (!_Sidebar) _Sidebar = require('/client/features/sidebar/service').getSidebarInstance;
|
||||
return _Sidebar();
|
||||
}
|
||||
|
||||
// Pressing `Escape` should close the last opened "element" and only the last
|
||||
// one. Components can register themselves using a label a condition, and an
|
||||
@@ -128,10 +122,6 @@ export const EscapeActions = {
|
||||
// in the hotkeys filter (keyboard.js) so it works in textarea and inputs.
|
||||
hotkeys('escape', () => {
|
||||
EscapeActions.executeLowest();
|
||||
const sidebar = getSidebar();
|
||||
if (sidebar) {
|
||||
sidebar.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// On a left click on the document, we try to exectute one escape action (eg,
|
||||
|
||||
@@ -92,7 +92,12 @@ AccountsTemplates.configure({
|
||||
sendVerificationEmail: true,
|
||||
showForgotPasswordLink: !disableForgotPassword,
|
||||
forbidClientAccountCreation: disableRegistration,
|
||||
homeRoutePath: '/',
|
||||
onSubmitHook(error, state) {
|
||||
if (!error && (state === 'signUp' || state === 'signIn')) {
|
||||
FlowRouter.go('/');
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
// Display error to user
|
||||
const errorDiv = document.getElementById('login-error-message');
|
||||
|
||||
+42
-9
@@ -171,7 +171,7 @@ services:
|
||||
# Leave it unset or empty to skip migration.
|
||||
#-------------------------------------------------------------------------------------
|
||||
#wekan-migrate:
|
||||
# image: node:24.14.1-slim
|
||||
# image: node:24.15.0-slim
|
||||
# container_name: wekan-migrate
|
||||
# restart: "no"
|
||||
# networks:
|
||||
@@ -226,15 +226,34 @@ services:
|
||||
image: mongo:7
|
||||
container_name: wekan-db
|
||||
restart: always
|
||||
#command: mongod --logpath /dev/null --oplogSize 128 --replSet rs0 --quiet
|
||||
#command: mongod --oplogSize 128 --replSet rs0 --quiet
|
||||
#command: mongod --logpath /dev/null --storageEngine wiredTiger --wiredTigerCacheSizeGB 32 --timeZoneInfo /usr/share/zoneinfo --setParameter logicalSessionRefreshMillis=900000 --setParameter localLogicalSessionTimeoutMinutes=45 --oplogSize 20480 --replSet rs0 --quiet
|
||||
#command: mongod --storageEngine wiredTiger --wiredTigerCacheSizeGB 32 --timeZoneInfo /usr/share/zoneinfo --setParameter logicalSessionRefreshMillis=900000 --setParameter localLogicalSessionTimeoutMinutes=45 --oplogSize 20480 --replSet rs0 --quiet
|
||||
command: >
|
||||
sh -c "
|
||||
mongod --oplogSize 128 --replSet rs0 --quiet --bind_ip_all &
|
||||
until mongosh --eval 'rs.initiate()' > /dev/null 2>&1; do
|
||||
sh -c '
|
||||
mongod --storageEngine wiredTiger --wiredTigerCacheSizeGB 32 --timeZoneInfo /usr/share/zoneinfo --setParameter logicalSessionRefreshMillis=900000 --setParameter localLogicalSessionTimeoutMinutes=45 --oplogSize 20480 --replSet rs0 --bind_ip_all --quiet &
|
||||
until mongosh --host 127.0.0.1 --quiet --eval "
|
||||
try {
|
||||
const status = rs.status();
|
||||
quit(status.ok === 1 ? 0 : 1);
|
||||
} catch (e) {
|
||||
if (
|
||||
e.codeName === \"NotYetInitialized\" ||
|
||||
e.message.includes(\"no replset config has been received\") ||
|
||||
e.message.includes(\"not yet initialized\")
|
||||
) {
|
||||
rs.initiate({
|
||||
_id: \"rs0\",
|
||||
members: [{ _id: 0, host: \"wekandb:27017\" }]
|
||||
});
|
||||
quit(1);
|
||||
}
|
||||
quit(1);
|
||||
}
|
||||
" >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
done;
|
||||
wait"
|
||||
done
|
||||
wait
|
||||
'
|
||||
networks:
|
||||
- wekan-tier
|
||||
expose:
|
||||
@@ -294,7 +313,11 @@ services:
|
||||
#- METEOR_REACTIVITY_ORDER=polling
|
||||
# https://forums.meteor.com/t/meteor-3-5-beta-change-streams-performance-improvements/64461#change-streams-setup-3
|
||||
# https://github.com/meteor/meteor/blob/release-3.5/v3-docs/docs/performance/change-streams-observer-driver.md#choosing-the-reactivity-driver-order
|
||||
- METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
|
||||
# Use oplog,polling to fix performance until changeStreams is fixed at next release of Meteor 3.5 Beta:
|
||||
# https://github.com/wekan/wekan/issues/6307#issuecomment-4299349231
|
||||
- METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
- DDP_TRANSPORT=sockjs
|
||||
#- DDP_TRANSPORT=uws
|
||||
#-----------------------------------------------------------------
|
||||
# ==== WRITEABLE PATH FOR FILE UPLOADS ====
|
||||
- WRITABLE_PATH=/data
|
||||
@@ -535,6 +558,13 @@ services:
|
||||
#- OAUTH2_ADFS_ENABLED=false
|
||||
# Azure AD B2C. https://github.com/wekan/wekan/issues/5242
|
||||
#- OAUTH2_B2C_ENABLED=false
|
||||
# SECURITY (GHSA-mp7g-hj5q-gxhq): Link an OIDC login to a pre-existing
|
||||
# Wekan account (password/LDAP) with the same email. OFF by default; when
|
||||
# false an OIDC login whose email already exists is rejected instead of
|
||||
# merged, preventing account takeover via spoofed email claims. Only
|
||||
# enable if you fully trust your OIDC provider's email claims, and even
|
||||
# then the provider must send email_verified=true for the merge to happen.
|
||||
#- OAUTH2_MERGE_EXISTING_USERS=false
|
||||
# OAuth2 login style: popup or redirect.
|
||||
#- OAUTH2_LOGIN_STYLE=redirect
|
||||
# Application GUID captured during app registration:
|
||||
@@ -828,6 +858,9 @@ services:
|
||||
#- HEADER_LOGIN_FIRSTNAME=HEADERFIRSTNAME
|
||||
#- HEADER_LOGIN_LASTNAME=HEADERLASTNAME
|
||||
#- HEADER_LOGIN_EMAIL=HEADEREMAILADDRESS
|
||||
# Optional comma-separated trusted proxy source IPs for header login.
|
||||
# If empty, all source IPs are accepted (not recommended).
|
||||
#- HEADER_LOGIN_TRUSTED_IPS=127.0.0.1,10.0.0.2
|
||||
#---------------------------------------------------------------------
|
||||
# ==== LOGOUT TIMER, probably does not work yet ====
|
||||
# LOGOUT_WITH_TIMER : Enables or not the option logout with timer
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# History of Schema Migration Systems
|
||||
|
||||
1. At `server/migrations.js` was changes to database schema, but making modifications to thousands
|
||||
of cards did take many hours, it is too much time.
|
||||
2. There was Admin Panel options to migrate attachments from MongoDB GridFS to filesystem, but that could
|
||||
cause WeKan to become unresponsive, it did take too much resources.
|
||||
3. There was separate migration script to change database schema, but it was incomplete, some data was not migrated.
|
||||
4. Migrations were moved to opening of board, but that caused opening board to take too much time
|
||||
5. Migrations were moved to Right Sidebar/Board Settings/Migrations, but there was complains that
|
||||
it is too much try to get users to learn using those migrations, and it could take
|
||||
many hours for migrations to run at big board. Also, there was Responsible Security Disclosure
|
||||
that Board Admins have too much rights to change board structure so much, so migrations were moved
|
||||
to Admin Panel to be used only by Admins.
|
||||
6. At Admin Panel/Cron, migrations did not show progress of migrations well enough, it looked like no
|
||||
migrations were happening, migrations did take too much time, and WeKan did slow down while migrations
|
||||
were in progress, as was seen at increased CPU and RAM usage.
|
||||
7. Migrations were commented out, so that they would not slow down WeKan anymore.
|
||||
8. WeKan was migrated to Meteor 3.x.
|
||||
9. Someone added datetime migrations, to convert previous date strings to Date Objects, that PR was merged,
|
||||
but questions still is, does it cause too much slowdown. It should have been changed to detect and show
|
||||
any current date, detecting is it a string or Date Object.
|
||||
10. There was added code, that could show all Swimlanes/Lists/Cards without migrations, showing both
|
||||
previous Shared Lists and newest Per-Swimlane Lists, and also orphaned Swimlanes/Lists/Cards.
|
||||
But there was bug that something still disappeared when moving. It remains to be seen,
|
||||
can there continue to be showing all data without migrations, can that code be made to work,
|
||||
or is it really necessary to reintroduce migrations that take too much time.
|
||||
|
||||
How should migrations work?
|
||||
|
||||
a) Separate migrations script, that would be run before starting WeKan?
|
||||
b) Reading any database structure and showing data, without needing to migrate any data?
|
||||
c) Migrating while WeKan is running, that could cause slowdown?
|
||||
d) Some other way?
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Make sure to run as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: This script needs to be run as root."
|
||||
echo "Use for example 'sudo su' before running this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check that script has been given only one parameter
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Error: Give a short lowecase ascii database name a parameter."
|
||||
echo "Example: $0 <databasename>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DB_NAME=$1
|
||||
MONGO_PORT=27017
|
||||
MONGO_HOST="localhost"
|
||||
CONFIG_FILE="/etc/mongod.conf"
|
||||
ADMIN_FILE="admin.txt"
|
||||
TARGET_FILE="${DB_NAME}.txt"
|
||||
|
||||
# Function for generating random password
|
||||
generate_password() {
|
||||
openssl rand -base64 12 | tr -dc 'a-zA-Z0-9' | head -c 16
|
||||
}
|
||||
|
||||
# --- STEP 1: CHECK ADMIN-USER AND PASSWORDLESS MONGODB STATE ---
|
||||
|
||||
# Check is there already authorization enabled at mongod.conf
|
||||
IS_AUTH_ENABLED=$(grep -A 5 "^security:" "$CONFIG_FILE" 2>/dev/null | grep "authorization:" | grep -q "enabled"; echo $?)
|
||||
|
||||
if [ ! -f "$ADMIN_FILE" ]; then
|
||||
# If admin.txt is missing, but database already requires auth, passwordless usage is not possible
|
||||
if [ "$IS_AUTH_ENABLED" -eq 0 ]; then
|
||||
echo "Error: MongoDB is already protected (authorization: enabled), but file $ADMIN_FILE is missing."
|
||||
echo "Script can not create admin-user without access to database. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- There is no Admin yet. Creating Admin to passwordless database... ---"
|
||||
|
||||
ADMIN_USER="admin_root"
|
||||
ADMIN_PASS=$(generate_password)
|
||||
|
||||
# Creating admin-user localhost by using exception
|
||||
mongosh --port $MONGO_PORT --eval "
|
||||
db = db.getSiblingDB('admin');
|
||||
db.createUser({
|
||||
user: '${ADMIN_USER}',
|
||||
pwd: '${ADMIN_PASS}',
|
||||
roles: [ { role: 'root', db: 'admin' } ]
|
||||
});
|
||||
" > /dev/null
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Creating Admin-user failed. Check is MongoDB running?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Save Admin-user details
|
||||
ADMIN_URL="mongodb://${ADMIN_USER}:${ADMIN_PASS}@${MONGO_HOST}:${MONGO_PORT}/admin"
|
||||
echo "MONGO_URL=${ADMIN_URL}" > "$ADMIN_FILE"
|
||||
echo "MONGOSH_CMD=mongosh \"${ADMIN_URL}\"" >> "$ADMIN_FILE"
|
||||
|
||||
echo "Admin-user created successfully."
|
||||
echo "--- Changing MongoDB to require login (/etc/mongod.conf) ---"
|
||||
|
||||
# Editing mongod.conf settings
|
||||
if grep -q "^security:" "$CONFIG_FILE"; then
|
||||
if grep -A 5 "^security:" "$CONFIG_FILE" | grep -q "authorization:"; then
|
||||
sed -i '/authorization:/c\ authorization: enabled' "$CONFIG_FILE"
|
||||
else
|
||||
sed -i '/^security:/a\ authorization: enabled' "$CONFIG_FILE"
|
||||
fi
|
||||
else
|
||||
echo -e "\nsecurity:\n authorization: enabled" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
echo "--- Restarting MongoDB, so that using password is always required... ---"
|
||||
systemctl restart mongod
|
||||
sleep 2
|
||||
echo "MongoDB is password protected and restarted."
|
||||
else
|
||||
echo "--- Found existing $ADMIN_FILE. Reading admin-password... ---"
|
||||
SAVED_URL=$(grep "MONGO_URL=" "$ADMIN_FILE" | cut -d'=' -f2)
|
||||
ADMIN_USER=$(echo "$SAVED_URL" | sed -e 's|mongodb://||' -e 's|@.*||' | cut -d':' -f1)
|
||||
ADMIN_PASS=$(echo "$SAVED_URL" | sed -e 's|mongodb://||' -e 's|@.*||' | cut -d':' -f2)
|
||||
fi
|
||||
|
||||
|
||||
# --- STEP 2: CHECK DOES TARGET DATABASE AND USER EXIST ---
|
||||
|
||||
echo "--- Check does database or user already exist... ---"
|
||||
|
||||
# Ask MongoDB is there does user already exist
|
||||
USER_EXISTS=$(mongosh --port $MONGO_PORT -u "$ADMIN_USER" -p "$ADMIN_PASS" --authenticationDatabase "admin" --quiet --eval "
|
||||
db = db.getSiblingDB('${DB_NAME}');
|
||||
print(db.getUser('${DB_NAME}_user') !== null);
|
||||
")
|
||||
|
||||
if [ "$USER_EXISTS" == "true" ] || [ -f "$TARGET_FILE" ]; then
|
||||
echo "--------------------------------------------------"
|
||||
echo "Database user '${DB_NAME}_user' or file '$TARGET_FILE' already exists."
|
||||
echo "Script did not make any changes. Exiting."
|
||||
echo "--------------------------------------------------"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
# --- STEP 3: CREATE READOPLOGONLY-ROLE (IF NOT EXIST YET) ---
|
||||
|
||||
echo "--- Checking does readOplogOnly-role exist at admin-database... ---"
|
||||
|
||||
mongosh --port $MONGO_PORT \
|
||||
-u "$ADMIN_USER" \
|
||||
-p "$ADMIN_PASS" \
|
||||
--authenticationDatabase "admin" \
|
||||
--eval "
|
||||
db = db.getSiblingDB('admin');
|
||||
if (db.getRole('readOplogOnly') === null) {
|
||||
db.runCommand({
|
||||
createRole: 'readOplogOnly',
|
||||
privileges: [
|
||||
{
|
||||
resource: { db: 'local', collection: 'oplog.rs' },
|
||||
actions: [ 'find' ]
|
||||
}
|
||||
],
|
||||
roles: []
|
||||
});
|
||||
print('Role created.');
|
||||
} else {
|
||||
print('Role already exists.');
|
||||
}
|
||||
" > /dev/null
|
||||
|
||||
|
||||
# --- STEP 4: CREATE DATABASE ACCORGIND TO PARAMETER ---
|
||||
|
||||
echo "--- Creating database '$DB_NAME' and user... ---"
|
||||
|
||||
TARGET_PASS=$(generate_password)
|
||||
|
||||
mongosh --port $MONGO_PORT \
|
||||
-u "$ADMIN_USER" \
|
||||
-p "$ADMIN_PASS" \
|
||||
--authenticationDatabase "admin" \
|
||||
--eval "
|
||||
db = db.getSiblingDB('${DB_NAME}');
|
||||
db.createUser({
|
||||
user: '${DB_NAME}_user',
|
||||
pwd: '${TARGET_PASS}',
|
||||
roles: [
|
||||
{ role: 'readWrite', db: '${DB_NAME}' },
|
||||
{ role: 'dbAdmin', db: '${DB_NAME}' },
|
||||
{ role: 'readOplogOnly', db: 'admin' }
|
||||
]
|
||||
});
|
||||
" > /dev/null
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
# Creating also ready MONGO_OPLOG_URL to output / file for ease of use
|
||||
TARGET_URL="mongodb://${DB_NAME}_user:${TARGET_PASS}@${MONGO_HOST}:${MONGO_PORT}/${DB_NAME}?authSource=${DB_NAME}"
|
||||
OPLOG_URL="mongodb://${DB_NAME}_user:${TARGET_PASS}@${MONGO_HOST}:${MONGO_PORT}/local?replicaSet=rs0&authSource=${DB_NAME}"
|
||||
|
||||
echo "MONGO_URL=${TARGET_URL}" > "$TARGET_FILE"
|
||||
echo "MONGO_OPLOG_URL=${OPLOG_URL}" >> "$TARGET_FILE"
|
||||
|
||||
echo "--------------------------------------------------"
|
||||
echo "Ready! Database and user created successfully."
|
||||
echo "New database details saved to $TARGET_FILE"
|
||||
echo "--------------------------------------------------"
|
||||
else
|
||||
echo "Error: Creating database failed."
|
||||
exit 1
|
||||
fi
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Make sure to run as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: This script needs to be run as root."
|
||||
echo "Use for example 'sudo su' before running this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check that script has been given only one parameter
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Error: Give a short lowecase ascii database name a parameter."
|
||||
echo "Example: $0 <databasename>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p restore/$1
|
||||
cat restore/docker-start.yml > restore/$1/docker-compose.yml
|
||||
sed -i "s|PARAMETER|${1}|g" restore/$1/docker-compose.yml
|
||||
cat $1.txt >> restore/$1/docker-compose.yml
|
||||
cat snap-settings/wekan_$1.txt >> restore/$1/docker-compose.yml
|
||||
cat restore/docker-end.yml >> restore/$1/docker-compose.yml
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Error: Enter the name of the database to restore as a parameter."
|
||||
echo "Usage: $0 <database_name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DB_PARAM=$1
|
||||
RESTORE_DIR=restore
|
||||
TARGET_FILE="${DB_PARAM}.txt"
|
||||
|
||||
# Checking the identity file
|
||||
if [ ! -f "$TARGET_FILE" ]; then
|
||||
echo "Error: File '$TARGET_FILE' not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# IMPORTANT CHANGE: Pointing to the dump folder as the root, not the wekan folder!
|
||||
if [ -d "./${RESTORE_DIR}/${DB_PARAM}/dump" ]; then
|
||||
BACKUP_DIR="./${RESTORE_DIR}/${DB_PARAM}/dump"
|
||||
else
|
||||
BACKUP_DIR="./${RESTORE_DIR}/${DB_PARAM}"
|
||||
fi
|
||||
|
||||
# Retrieving MONGO_URL
|
||||
MONGO_URL=$(grep "^MONGO_URL=" "$TARGET_FILE" | cut -d'=' -f2-)
|
||||
if [[ -z "$MONGO_URL" || ! "$MONGO_URL" =~ ^mongodb:// ]]; then
|
||||
echo "Error: No valid MONGO_URL line found in file $TARGET_FILE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONNECTION_URI=$(echo "$MONGO_URL" | sed 's/\/[^\/]*$/\//')
|
||||
|
||||
echo "--- Starting the restore of database '$DB_PARAM' ---"
|
||||
echo "Using backup from: $BACKUP_DIR"
|
||||
echo "---------------------------------------------------"
|
||||
|
||||
# Run mongorestore by giving it a dump folder.
|
||||
# The tool reads the "wekan" subfolder from there and changes it to "PARAMETER".
|
||||
mongorestore --uri="$CONNECTION_URI" \
|
||||
--authenticationDatabase="$DB_PARAM" \
|
||||
--drop \
|
||||
--nsFrom="wekan.*" \
|
||||
--nsTo="${DB_PARAM}.*" \
|
||||
--dir="$BACKUP_DIR"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "--------------------------------------------------"
|
||||
echo "Done! Database '$DB_PARAM' restored successfully."
|
||||
echo "------------------------------------------------"
|
||||
else
|
||||
echo "Error: mongorestore failed."
|
||||
exit 1
|
||||
fi
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Transfer attachments and avatars from server c1 Snap to server c2 Docker
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Error: Enter the name of what snap files to transfer to Docker files."
|
||||
echo "Usage: $0 <database_name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rsync -aur --progress c1:/var/snap/wekan_$1/common/files /var/lib/docker/volumes/$1_wekan-files/_data/
|
||||
chown -R caddy:systemd-journal /var/lib/docker/volumes/$1_wekan-files/_data/files
|
||||
|
||||
# At c2:
|
||||
# ssh-keygen
|
||||
|
||||
# At c2 ~/.ssh/config:
|
||||
# Host c1
|
||||
# Hostname 123.123.123.123
|
||||
# User root
|
||||
# IdentityFile ~/.ssh/id_rsa
|
||||
|
||||
# At c1 ~/.ssh/authorized_keys has content of id_rsa at c1:
|
||||
# ssh-rsa ... root@c2
|
||||
|
||||
# At c1 file permissions:
|
||||
# chmod og-rwx ~/.ssh/authorized_keys
|
||||
@@ -0,0 +1,94 @@
|
||||
# Meteor 3 WeKan: Hosting many WeKan kanban, websites, etc
|
||||
|
||||
Note: Only `Oplog sockjs` works with multitenancy, see [multitenancy.md](multitenancy).
|
||||
|
||||
Migrating from Parallel Snap setup, because `snap set wekan root-url=...`
|
||||
etc commands are slow to run, and did not get Parallel Snap working properly yet.
|
||||
|
||||
```
|
||||
caddy proxy: Let's Encrypt and CloudFlare TLS to localhost http ports
|
||||
|
|
||||
|==> Many Parallel WeKan Snap Containers at different ports,
|
||||
| each Container with it's own Node.js/MongoDB
|
||||
|==> Sandstorm, many websites with WordPress and Hackers CMS
|
||||
|==> PHP/MySQL WordPress (example)
|
||||
|==> PHP/MySQL Hesk Helpdesk (example)
|
||||
|==> PHP/MySQL Kanboard (example)
|
||||
|==> Static websites (example)
|
||||
```
|
||||
To this Docker setup:
|
||||
```
|
||||
caddy proxy: Let's Encrypt and CloudFlare TLS to localhost http ports
|
||||
|
|
||||
|==> Many Docker Meteor 3 WeKan Node.js Containers,
|
||||
| Uses changeStreams and DDP_TRANSPORT=uws
|
||||
| |
|
||||
| |=> One MongoDB without container,
|
||||
| always requires password to login,
|
||||
| for admin and each other users,
|
||||
| each user has it's own database, username and password
|
||||
|
|
||||
|==> Sandstorm, many websites with WordPress and Hackers CMS
|
||||
|==> PHP/MySQL WordPress (example)
|
||||
|==> PHP/MySQL Hesk Helpdesk (example)
|
||||
|==> PHP/MySQL Kanboard (example)
|
||||
|==> Static websites (example)
|
||||
```
|
||||
Save all Parallel Snap container settings with:
|
||||
```
|
||||
snap-save-settings.sh
|
||||
```
|
||||
For setting up MongoDB etc, use examples at `apt` directory.
|
||||
|
||||
MongoDB config `/etc/mongod.conf` is at `mongo` directory.
|
||||
|
||||
Create MongoDB key:
|
||||
```
|
||||
./keys.sh
|
||||
```
|
||||
1) Adding new user, it saves MongoDB connection string to username.txt:
|
||||
```
|
||||
./1createdb.sh username
|
||||
```
|
||||
Same username.txt is used when login to database, backup, restore, files transfer, etc.
|
||||
|
||||
You can connect to database:
|
||||
```
|
||||
./sh.sh username
|
||||
```
|
||||
Or sometime make Backup to `backup/username/YYYY-MM-DD_HH-MM-SS/`
|
||||
```
|
||||
./backup.sh username
|
||||
```
|
||||
2) Create docker-compose.yml to `restore/username/`
|
||||
```
|
||||
./2docker.sh username
|
||||
```
|
||||
Edit docker-compose.yml and start Docker container:
|
||||
```
|
||||
cd restore/username/
|
||||
|
||||
nano docker-compose.yml
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
3) Restore database
|
||||
```
|
||||
./3restoredb.sh username
|
||||
```
|
||||
4) Transfer attachments and avatars from Snap server to Docker server:
|
||||
```
|
||||
./4files.sh username
|
||||
```
|
||||
5) Restart Docker container:
|
||||
```
|
||||
cd restore/username/
|
||||
|
||||
docker compose stop
|
||||
|
||||
docker compose start
|
||||
```
|
||||
6) Upgrade WeKan from old version to new version, for example:
|
||||
```
|
||||
./update-wekan-version.sh 9.10 9.11"
|
||||
```
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
|
||||
sudo apt update
|
||||
sudo apt install caddy
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
apt clean
|
||||
apt -y autoclean
|
||||
apt -y autoremove
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
apt update
|
||||
apt -y dist-upgrade
|
||||
snap refresh
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Loop through all subdirectories in the current directory
|
||||
for d in restore/*/ ; do
|
||||
# Get only directory name, without restore/ part
|
||||
FOLDER_NAME=$(basename "$d")
|
||||
echo "--------------------------------------------------"
|
||||
echo "Creating backup: $FOLDER_NAME"
|
||||
echo "--------------------------------------------------"
|
||||
./backup.sh "$FOLDER_NAME" || continue
|
||||
done
|
||||
|
||||
echo "All backups done!"
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check that the script was given exactly one parameter
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Error: Enter the name of the database to be backed up as a parameter."
|
||||
echo "Usage: $0 <database_name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DB_PARAM=$1
|
||||
TARGET_FILE="${DB_PARAM}.txt"
|
||||
|
||||
# 1. Check if the credentials file exists
|
||||
if [ ! -f "$TARGET_FILE" ]; then
|
||||
echo "Error: File '$TARGET_FILE' not found. Unable to retrieve login information."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Retrieve MONGO_URL from file
|
||||
MONGO_URL=$(grep "^MONGO_URL=" "$TARGET_FILE" | cut -d'=' -f2-)
|
||||
|
||||
if [[ -z "$MONGO_URL" || ! "$MONGO_URL" =~ ^mongodb:// ]]; then
|
||||
echo "Error: No valid MONGO_URL row found in file $TARGET_FILE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Remove the database name from the end of the URL for mongodump,
|
||||
# so that the connection is made to the server root (e.g. mongodb://user:pass@host:port/)
|
||||
CONNECTION_URI=$(echo "$MONGO_URL" | sed 's/\/[^\/]*$/\//')
|
||||
|
||||
# 4. Create a timestamp and destination folder path (Format: YYYY-MM-DD_HH-MM-SS)
|
||||
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
|
||||
BACKUP_PATH="backup/${DB_PARAM}/${TIMESTAMP}"
|
||||
|
||||
echo "--- Starting backup of database '$DB_PARAM' ---"
|
||||
echo "Destination folder: $BACKUP_PATH"
|
||||
echo "--------------------------------------------------"
|
||||
|
||||
# Run mongodump
|
||||
# --uri = connection address with ID
|
||||
# --db = from which database the dump is taken
|
||||
# --out = destination folder where the data is saved
|
||||
mongodump --uri="$CONNECTION_URI" --db="$DB_PARAM" --out="$BACKUP_PATH"
|
||||
|
||||
# Backup attachments and avatars
|
||||
cp -pR "/var/lib/docker/volumes/${DB_PARAM}_wekan-files/_data/files" "${BACKUP_PATH}/"
|
||||
|
||||
# Check if the backup was successful
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "------------------------------------------------"
|
||||
echo "Done! Backup saved successfully to:"
|
||||
echo "$BACKUP_PATH"
|
||||
echo "------------------------------------------------"
|
||||
else
|
||||
echo "Error: mongodump failed."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,440 @@
|
||||
# REQUIRED:
|
||||
# - CloudFlare/WeKan Domain
|
||||
# - Speed/Settings: Disable only "HTTP/2 to Origin"
|
||||
# - Caching/Overview: Development Mode, Temporarily bypass cache
|
||||
# - See screenshots at cloudflare directory
|
||||
|
||||
{
|
||||
# Using below "servers {protocols h1}" to force Caddy to use only
|
||||
# HTTP/1.1 -protocol (h1) at background connections,
|
||||
# to prevent multitenancy round robin bug, see multitenancy.md
|
||||
servers {
|
||||
protocols h1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Redirecting http to https
|
||||
|
||||
(redirect) {
|
||||
@http {
|
||||
protocol http
|
||||
}
|
||||
redir @http https://{host}{uri}
|
||||
}
|
||||
|
||||
## Hosting examples
|
||||
|
||||
# 1) WeKan hosting: Custom Domain with default Let's Encrypt TLS certificate
|
||||
# - at CloudFlare DNS there is grey cloud, no CloudFlare proxy
|
||||
# - Customer DNS: wekan.company.com CNAME customer.wekan.team
|
||||
# - WeKan hosting CloudFlare DNS: customer.wekan.team A Server-IPv4-Address
|
||||
|
||||
wekan.customer.com {
|
||||
# b) No custom domain, using customer.wekan.team:
|
||||
# - Using CloudFlare TLS Origin Certificate with Strict Enforcement
|
||||
# - At CloudFlare DNS change to orange cloud, with CloudFlare proxy,
|
||||
# - https://github.com/wekan/wekan/blob/main/docs/Webserver/Caddy.md
|
||||
# - Add this tls section to this example:
|
||||
# tls {
|
||||
# load /etc/caddy/certs
|
||||
# }
|
||||
header {
|
||||
-Content-Security-Policy-Report-Only
|
||||
-Content-Security-Policy
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://static.cloudflareinsights.com https://accounts.google.com https://apis.google.com; worker-src 'self' blob:; connect-src 'self' wss://wekan.customer.com https://wekan.customer.com https://cloudflareinsights.com https://accounts.google.com; img-src 'self' data: https://*.googleusercontent.com; frame-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:;"
|
||||
Cross-Origin-Opener-Policy "same-origin-allow-popups"
|
||||
X-Content-Type-Options nosniff
|
||||
Strict-Transport-Security "max-age=31536000;"
|
||||
-X-Frame-Options
|
||||
Cache-Control "no-cache, no-store, must-revalidate"
|
||||
}
|
||||
handle /.well-known/* {
|
||||
header Content-Type application/json
|
||||
header Access-Control-Allow-Origin *
|
||||
}
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3039
|
||||
}
|
||||
}
|
||||
|
||||
## 2) WeKan hosting: No custom domain, using customer.wekan.team:
|
||||
# - Using CloudFlare TLS Origin Certificate with Strict Enforcement
|
||||
# - At CloudFlare DNS change to orange cloud, with CloudFlare proxy,
|
||||
# - https://github.com/wekan/wekan/blob/main/docs/Webserver/Caddy.md
|
||||
# - Difference is additional tls section: tls { load /etc/caddy/certs }
|
||||
|
||||
customer.wekan.team {
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
header {
|
||||
-Content-Security-Policy-Report-Only
|
||||
-Content-Security-Policy
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://static.cloudflareinsights.com https://accounts.google.com https://apis.google.com; worker-src 'self' blob:; connect-src 'self' wss://customer.wekan.team https://customer.wekan.team https://cloudflareinsights.com https://accounts.google.com; img-src 'self' data: https://*.googleusercontent.com; frame-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:;"
|
||||
Cross-Origin-Opener-Policy "same-origin-allow-popups"
|
||||
X-Content-Type-Options nosniff
|
||||
Strict-Transport-Security "max-age=31536000;"
|
||||
-X-Frame-Options
|
||||
Cache-Control "no-cache, no-store, must-revalidate"
|
||||
}
|
||||
handle /.well-known/* {
|
||||
header Content-Type application/json
|
||||
header Access-Control-Allow-Origin *
|
||||
}
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3040
|
||||
}
|
||||
}
|
||||
|
||||
# 3) Static website
|
||||
|
||||
# 3.1) Static Website: Let's Encrypt TLS
|
||||
customer.com {
|
||||
root * /data/websites/customer.com
|
||||
file_server
|
||||
}
|
||||
|
||||
# 3.2) Static Website: CloudFlare TLS
|
||||
customer.com {
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
root * /data/websites/customer.com
|
||||
file_server
|
||||
}
|
||||
|
||||
# 4) Example of Sandstorm https://sandstorm.org web hosting
|
||||
# - with WordPress, Hacker CMS, etc
|
||||
# - https://github.com/wekan/wekan/blob/main/docs/Platforms/FOSS/Sandstorm/Sandstorm.md
|
||||
|
||||
# 4.1) Using Let's Encrypt: WordPress, Hacker CMS, etc
|
||||
customer.fi {
|
||||
reverse_proxy 127.0.0.1:81
|
||||
}
|
||||
|
||||
# 4.2) Using CloudFlare TLS: WordPress, Hacker CMS, etc
|
||||
blog.wekan.fi {
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
reverse_proxy 127.0.0.1:81
|
||||
}
|
||||
|
||||
# 4.3) Sandstorm backup: /opt/backup.sh
|
||||
#
|
||||
# #!/bin/bash
|
||||
# sandstorm stop
|
||||
# tar -cf sandstorm-backup-$(date -I).tar /opt/sandstorm
|
||||
# sandstorm start
|
||||
|
||||
# 4.4) Sandstorm config: /opt/sanstorm/sandstorm.conf
|
||||
#
|
||||
# SERVER_USER=sandstorm
|
||||
# PORT=81
|
||||
# MONGO_PORT=6081
|
||||
# BIND_IP=127.0.0.1
|
||||
# BASE_URL=https://wekan.dev
|
||||
# WILDCARD_HOST=*.wekan.dev
|
||||
# UPDATE_CHANNEL=dev
|
||||
# ALLOW_DEV_ACCOUNTS=false
|
||||
# SMTP_LISTEN_PORT=25
|
||||
###### OLD, NOT IN USE:
|
||||
### SANDCATS_BASE_DOMAIN=sandcats.io
|
||||
### HTTPS_PORT=443
|
||||
|
||||
# 5) PHP/MySQL Wordpress
|
||||
|
||||
customer.com {
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
root * /data/websites/customer.com
|
||||
encode gzip
|
||||
file_server
|
||||
php_fastcgi unix//run/php/php-fpm.sock
|
||||
|
||||
@disallowed {
|
||||
path /xmlrpc.php
|
||||
path *.sql
|
||||
path /wp-content/uploads/*.php
|
||||
}
|
||||
|
||||
rewrite @disallowed '/index.php'
|
||||
}
|
||||
|
||||
# 6) Kanboard
|
||||
|
||||
# 6.1) Let's Encrypt TLS
|
||||
|
||||
kanboard.customer.com {
|
||||
root * /data/websites/kanboard.customer.com
|
||||
encode gzip
|
||||
php_fastcgi unix//run/php/php-fpm.sock
|
||||
try_files {path} {path}/ /index.php?{query}
|
||||
file_server
|
||||
log {
|
||||
output file /var/log/caddy/kanboard_access.log
|
||||
}
|
||||
}
|
||||
|
||||
# 6.2) CloudFlare TLS
|
||||
|
||||
kanboard.customer.com {
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
root * /data/websites/kanboard.customer.com
|
||||
encode gzip
|
||||
php_fastcgi unix//run/php/php-fpm.sock
|
||||
try_files {path} {path}/ /index.php?{query}
|
||||
file_server
|
||||
log {
|
||||
output file /var/log/caddy/kanboard_access.log
|
||||
}
|
||||
}
|
||||
|
||||
# 7) Hesk Helpdesk
|
||||
|
||||
# 7.1) Let's Encrypt
|
||||
|
||||
hesk.customer.com {
|
||||
root * /data/websites/hesk.customer.com
|
||||
encode zstd gzip
|
||||
php_fastcgi unix//run/php/php-fpm.sock
|
||||
file_server
|
||||
log {
|
||||
output file /var/log/caddy/hesk_access.log
|
||||
}
|
||||
}
|
||||
|
||||
# 7.1) CloudFlare TLS
|
||||
|
||||
hesk.customer.com {
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
root * /data/websites/hesk.customer.com
|
||||
encode zstd gzip
|
||||
php_fastcgi unix//run/php/php-fpm.sock
|
||||
file_server
|
||||
log {
|
||||
output file /var/log/caddy/hesk_access.log
|
||||
}
|
||||
}
|
||||
|
||||
# 8) (Note to myself: Some Apple developer example, not in use)
|
||||
# (Currently website at GitHub Pages, this is not in use)
|
||||
#wekan.fi www.wekan.fi {
|
||||
# tls {
|
||||
# load /etc/caddy/certs
|
||||
# }
|
||||
# header /.well-known/apple-developer-merchantid-domain-association/* Content-Type text/plain
|
||||
# header /.well-known/apple-developer-merchantid-domain-association/* Access-Control-Allow-Origin *
|
||||
# respond /.well-known/apple-developer-merchantid-domain-association `7B227073704964223A2246354246304143324336314131413238313043373531453439333444414433384346393037313041303935303844314133453241383436314141363232414145222C2276657273696F6E223A312C22637265617465644F6E223A313731353230343037313232362C227369676E6174757265223A223330383030363039326138363438383666373064303130373032613038303330383030323031303133313064333030623036303936303836343830313635303330343032303133303830303630393261383634383836663730643031303730313030303061303830333038323033653333303832303338386130303330323031303230323038313636333463386230653330353731373330306130363038326138363438636533643034303330323330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333031653137306433323334333033343332333933313337333433373332333735613137306433323339333033343332333833313337333433373332333635613330356633313235333032333036303335353034303330633163363536333633326437333664373032643632373236663662363537323264373336393637366535663535343333343264353035323466343433313134333031323036303335353034306230633062363934663533323035333739373337343635366437333331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333035393330313330363037326138363438636533643032303130363038326138363438636533643033303130373033343230303034633231353737656465626436633762323231386636386464373039306131323138646337623062643666326332383364383436303935643934616634613534313162383334323065643831316633343037653833333331663163353463336637656233323230643662616435643465666634393238393839336537633066313361333832303231313330383230323064333030633036303335353164313330313031666630343032333030303330316630363033353531643233303431383330313638303134323366323439633434663933653465663237653663346636323836633366613262626664326534623330343530363038326230363031303530353037303130313034333933303337333033353036303832623036303130353035303733303031383632393638373437343730336132663266366636333733373032653631373037303663363532653633366636643266366636333733373033303334326436313730373036633635363136393633363133333330333233303832303131643036303335353164323030343832303131343330383230313130333038323031306330363039326138363438383666373633363430353031333038316665333038316333303630383262303630313035303530373032303233303831623630633831623335323635366336393631366536333635323036663665323037343638363937333230363336353732373436393636363936333631373436353230363237393230363136653739323037303631373237343739323036313733373337353664363537333230363136333633363537303734363136653633363532303666363632303734363836353230373436383635366532303631373037303663363936333631363236633635323037333734363136653634363137323634323037343635373236643733323036313665363432303633366636653634363937343639366636653733323036663636323037353733363532633230363336353732373436393636363936333631373436353230373036663663363936333739323036313665363432303633363537323734363936363639363336313734363936663665323037303732363136333734363936333635323037333734363137343635366436353665373437333265333033363036303832623036303130353035303730323031313632613638373437343730336132663266373737373737326536313730373036633635326536333666366432663633363537323734363936363639363336313734363536313735373436383666373236393734373932663330333430363033353531643166303432643330326233303239613032376130323538363233363837343734373033613266326636333732366332653631373037303663363532653633366636643266363137303730366336353631363936333631333332653633373236633330316430363033353531643065303431363034313439343537646236666435373438313836383938393736326637653537383530376537396235383234333030653036303335353164306630313031666630343034303330323037383033303066303630393261383634383836663736333634303631643034303230353030333030613036303832613836343863653364303430333032303334393030333034363032323130306336663032336362323631346262333033383838613136323938336531613933663130353666353066613738636462396261346361323431636331346532356530323231303062653363643064666431363234376636343934343735333830653964343463323238613130383930613361316463373234623862346362383838393831386263333038323032656533303832303237356130303330323031303230323038343936643266626633613938646139373330306130363038326138363438636533643034303330323330363733313162333031393036303335353034303330633132343137303730366336353230353236663666373432303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343Line truncated
|
||||
# root * /data/websites/wekan.fi
|
||||
# file_server
|
||||
#}
|
||||
|
||||
|
||||
## 9) WeKan app https://wekan.fi/app/
|
||||
|
||||
# Note: It is not necessary to define all icons etc here at Caddyfile,
|
||||
# because PWA related icons etc settings are also at
|
||||
# newest WeKan Admin Panel / Settings.
|
||||
|
||||
boards.wekan.team {
|
||||
tls {
|
||||
load /etc/caddy/certs
|
||||
}
|
||||
header {
|
||||
-Content-Security-Policy-Report-Only
|
||||
-Content-Security-Policy
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://static.cloudflareinsights.com https://accounts.google.com https://apis.google.com; worker-src 'self' blob:; connect-src 'self' wss://boards.wekan.team https://boards.wekan.team https://cloudflareinsights.com https://accounts.google.com; img-src 'self' data: https://*.googleusercontent.com; frame-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:;"
|
||||
Cross-Origin-Opener-Policy "same-origin-allow-popups"
|
||||
X-Content-Type-Options nosniff
|
||||
Strict-Transport-Security "max-age=31536000;"
|
||||
-X-Frame-Options
|
||||
Cache-Control "no-cache, no-store, must-revalidate"
|
||||
}
|
||||
|
||||
handle /.well-known/assetlinks.json {
|
||||
header Content-Type application/json
|
||||
header Access-Control-Allow-Origin *
|
||||
respond `[ {
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "team.wekan.boards.twa",
|
||||
"sha256_cert_fingerprints": [
|
||||
"61:41:86:5B:05:13:9B:64:5F:39:75:5A:16:C3:F2:22:25:6C:DA:74:B9:B0:8C:5F:93:B0:D2:26:65:16:1B:E6",
|
||||
"AA:AA:ED:7D:4C:9C:5A:A3:B5:DA:10:66:14:34:07:5D:EB:BE:96:CD:82:7B:09:46:47:13:65:29:5B:EA:96:30"
|
||||
]
|
||||
}
|
||||
} ]`
|
||||
}
|
||||
|
||||
handle /.well-known/* {
|
||||
header Content-Type application/json
|
||||
header Access-Control-Allow-Origin *
|
||||
}
|
||||
|
||||
handle /site.webmanifest {
|
||||
header Content-Type application/manifest+json
|
||||
header Access-Control-Allow-Origin *
|
||||
respond ` {
|
||||
"name": "Wekan",
|
||||
"short_name": "Wekan",
|
||||
"icons": [
|
||||
{ "src": "svg-etc/wekan-logo-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "Square150x150Logo.scale-100.png", "sizes": "150x150", "type": "image/png" },
|
||||
{ "src": "Square44x44Logo.scale-100.png", "sizes": "44x44", "type": "image/png" },
|
||||
{ "src": "StoreLogo.scale-100.png", "sizes": "50x50", "type": "image/png" },
|
||||
{ "src": "maskable_icon.png", "sizes": "474x474", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "monochrome-icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "monochrome" },
|
||||
{ "src": "windows11/SmallTile.scale-100.png", "sizes": "71x71" },
|
||||
{ "src": "windows11/SmallTile.scale-125.png", "sizes": "89x89" },
|
||||
{ "src": "windows11/SmallTile.scale-150.png", "sizes": "107x107" },
|
||||
{ "src": "windows11/SmallTile.scale-200.png", "sizes": "142x142" },
|
||||
{ "src": "windows11/SmallTile.scale-400.png", "sizes": "284x284" },
|
||||
{ "src": "windows11/Square150x150Logo.scale-100.png", "sizes": "150x150" },
|
||||
{ "src": "windows11/Square150x150Logo.scale-125.png", "sizes": "188x188" },
|
||||
{ "src": "windows11/Square150x150Logo.scale-150.png", "sizes": "225x225" },
|
||||
{ "src": "windows11/Square150x150Logo.scale-200.png", "sizes": "300x300" },
|
||||
{ "src": "windows11/Square150x150Logo.scale-400.png", "sizes": "600x600" },
|
||||
{ "src": "windows11/Wide310x150Logo.scale-100.png", "sizes": "310x150" },
|
||||
{ "src": "windows11/Wide310x150Logo.scale-125.png", "sizes": "388x188" },
|
||||
{ "src": "windows11/Wide310x150Logo.scale-150.png", "sizes": "465x225" },
|
||||
{ "src": "windows11/Wide310x150Logo.scale-200.png", "sizes": "620x300" },
|
||||
{ "src": "windows11/Wide310x150Logo.scale-400.png", "sizes": "1240x600" },
|
||||
{ "src": "windows11/LargeTile.scale-100.png", "sizes": "310x310" },
|
||||
{ "src": "windows11/LargeTile.scale-125.png", "sizes": "388x388" },
|
||||
{ "src": "windows11/LargeTile.scale-150.png", "sizes": "465x465" },
|
||||
{ "src": "windows11/LargeTile.scale-200.png", "sizes": "620x620" },
|
||||
{ "src": "windows11/LargeTile.scale-400.png", "sizes": "1240x1240" },
|
||||
{ "src": "windows11/Square44x44Logo.scale-100.png", "sizes": "44x44" },
|
||||
{ "src": "windows11/Square44x44Logo.scale-125.png", "sizes": "55x55" },
|
||||
{ "src": "windows11/Square44x44Logo.scale-150.png", "sizes": "66x66" },
|
||||
{ "src": "windows11/Square44x44Logo.scale-200.png", "sizes": "88x88" },
|
||||
{ "src": "windows11/Square44x44Logo.scale-400.png", "sizes": "176x176" },
|
||||
{ "src": "windows11/StoreLogo.scale-100.png", "sizes": "50x50" },
|
||||
{ "src": "windows11/StoreLogo.scale-125.png", "sizes": "63x63" },
|
||||
{ "src": "windows11/StoreLogo.scale-150.png", "sizes": "75x75" },
|
||||
{ "src": "windows11/StoreLogo.scale-200.png", "sizes": "100x100" },
|
||||
{ "src": "windows11/StoreLogo.scale-400.png", "sizes": "200x200" },
|
||||
{ "src": "windows11/SplashScreen.scale-100.png", "sizes": "620x300" },
|
||||
{ "src": "windows11/SplashScreen.scale-125.png", "sizes": "775x375" },
|
||||
{ "src": "windows11/SplashScreen.scale-150.png", "sizes": "930x450" },
|
||||
{ "src": "windows11/SplashScreen.scale-200.png", "sizes": "1240x600" },
|
||||
{ "src": "windows11/SplashScreen.scale-400.png", "sizes": "2480x1200" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-16.png", "sizes": "16x16" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-20.png", "sizes": "20x20" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-24.png", "sizes": "24x24" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-30.png", "sizes": "30x30" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-32.png", "sizes": "32x32" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-36.png", "sizes": "36x36" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-40.png", "sizes": "40x40" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-44.png", "sizes": "44x44" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-48.png", "sizes": "48x48" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-60.png", "sizes": "60x60" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-64.png", "sizes": "64x64" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-72.png", "sizes": "72x72" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-80.png", "sizes": "80x80" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-96.png", "sizes": "96x96" },
|
||||
{ "src": "windows11/Square44x44Logo.targetsize-256.png", "sizes": "256x256" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-16.png", "sizes": "16x16" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-20.png", "sizes": "20x20" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-24.png", "sizes": "24x24" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-30.png", "sizes": "30x30" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-32.png", "sizes": "32x32" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-36.png", "sizes": "36x36" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-40.png", "sizes": "40x40" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-44.png", "sizes": "44x44" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-48.png", "sizes": "48x48" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-60.png", "sizes": "60x60" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-64.png", "sizes": "64x64" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-72.png", "sizes": "72x72" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-80.png", "sizes": "80x80" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-96.png", "sizes": "96x96" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-unplated_targetsize-256.png", "sizes": "256x256" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-16.png", "sizes": "16x16" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-20.png", "sizes": "20x20" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-24.png", "sizes": "24x24" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-30.png", "sizes": "30x30" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-32.png", "sizes": "32x32" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-36.png", "sizes": "36x36" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-40.png", "sizes": "40x40" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-44.png", "sizes": "44x44" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-48.png", "sizes": "48x48" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-60.png", "sizes": "60x60" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-64.png", "sizes": "64x64" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-72.png", "sizes": "72x72" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-80.png", "sizes": "80x80" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-96.png", "sizes": "96x96" },
|
||||
{ "src": "windows11/Square44x44Logo.altform-lightunplated_targetsize-256.png", "sizes": "256x256" },
|
||||
{ "src": "android/android-launchericon-512-512.png", "sizes": "512x512" },
|
||||
{ "src": "android/android-launchericon-192-192.png", "sizes": "192x192" },
|
||||
{ "src": "android/android-launchericon-144-144.png", "sizes": "144x144" },
|
||||
{ "src": "android/android-launchericon-96-96.png", "sizes": "96x96" },
|
||||
{ "src": "android/android-launchericon-72-72.png", "sizes": "72x72" },
|
||||
{ "src": "android/android-launchericon-48-48.png", "sizes": "48x48" },
|
||||
{ "src": "ios/16.png", "sizes": "16x16" },
|
||||
{ "src": "ios/20.png", "sizes": "20x20" },
|
||||
{ "src": "ios/29.png", "sizes": "29x29" },
|
||||
{ "src": "ios/32.png", "sizes": "32x32" },
|
||||
{ "src": "ios/40.png", "sizes": "40x40" },
|
||||
{ "src": "ios/50.png", "sizes": "50x50" },
|
||||
{ "src": "ios/57.png", "sizes": "57x57" },
|
||||
{ "src": "ios/58.png", "sizes": "58x58" },
|
||||
{ "src": "ios/60.png", "sizes": "60x60" },
|
||||
{ "src": "ios/64.png", "sizes": "64x64" },
|
||||
{ "src": "ios/72.png", "sizes": "72x72" },
|
||||
{ "src": "ios/76.png", "sizes": "76x76" },
|
||||
{ "src": "ios/80.png", "sizes": "80x80" },
|
||||
{ "src": "ios/87.png", "sizes": "87x87" },
|
||||
{ "src": "ios/100.png", "sizes": "100x100" },
|
||||
{ "src": "ios/114.png", "sizes": "114x114" },
|
||||
{ "src": "ios/120.png", "sizes": "120x120" },
|
||||
{ "src": "ios/128.png", "sizes": "128x128" },
|
||||
{ "src": "ios/144.png", "sizes": "144x144" },
|
||||
{ "src": "ios/152.png", "sizes": "152x152" },
|
||||
{ "src": "ios/167.png", "sizes": "167x167" },
|
||||
{ "src": "ios/180.png", "sizes": "180x180" },
|
||||
{ "src": "ios/192.png", "sizes": "192x192" },
|
||||
{ "src": "ios/256.png", "sizes": "256x256" },
|
||||
{ "src": "ios/512.png", "sizes": "512x512" },
|
||||
{ "src": "ios/1024.png", "sizes": "1024x1024" }
|
||||
],
|
||||
"screenshots": [
|
||||
{ "src": "screenshot1.webp", "sizes": "1280x720", "type": "image/webp" },
|
||||
{ "src": "screenshot2.webp", "sizes": "1280x720", "type": "image/webp" }
|
||||
],
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"start_url": "sign-in",
|
||||
"id": "/",
|
||||
"launch_handler": { "client_mode": "navigate-existing" },
|
||||
"handle_links": "preferred",
|
||||
"related_applications": [
|
||||
{ "platform": "play", "url": "https://play.google.com/store/apps/details?id=team.wekan.boards.twa", "id": "team.wekan.boards.twa" },
|
||||
{ "platform": "windows", "url": "https://apps.microsoft.com/detail/9P2MRXVD087R", "id": "61178WekanTeam.Wekan" },
|
||||
{ "platform": "webapp", "url": "https://boards.wekan.team/sign-in", "id": "wekan.wekanteam" }
|
||||
],
|
||||
"display": "fullscreen",
|
||||
"orientation": "any",
|
||||
"categories": [ "productivity" ],
|
||||
"iarc_rating_id": "70d7c4a4-3e5a-4714-a7dc-fa006613ba96",
|
||||
"description": "Open Source kanban with MIT license",
|
||||
"dir": "auto",
|
||||
"lang": "en",
|
||||
"scope": "https://boards.wekan.team",
|
||||
"prefer_related_applications": false
|
||||
}`
|
||||
}
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3025
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
curl -s http://127.0.0.1:3025 | grep -o 'sockjs'
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
@@ -0,0 +1,13 @@
|
||||
# /etc/default/grub at Hetzner Ubuntu 26.04: mem_encrypt=on for
|
||||
# RAM Memory Encryption. Also using XFS for MongoDB with RAID.
|
||||
# ----
|
||||
# If you change this file or any /etc/default/grub.d/*.cfg file,
|
||||
# run 'update-grub' afterwards to update /boot/grub/grub.cfg.
|
||||
# For full documentation of the options in these files, see:
|
||||
# info -f grub -n 'Simple configuration'
|
||||
|
||||
GRUB_DEFAULT=0
|
||||
GRUB_TIMEOUT=5
|
||||
GRUB_DISTRIBUTOR=`( . /etc/os-release && echo ${NAME} )`
|
||||
GRUB_CMDLINE_LINUX_DEFAULT="consoleblank=0 mem_encrypt=on"
|
||||
GRUB_CMDLINE_LINUX="consoleblank=0 systemd.show_status=true"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Create MongoDB keyfile
|
||||
|
||||
# Make sure to run as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: This script needs to be run as root."
|
||||
echo "Use for example 'sudo su' before running this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create a folder for the key if it doesn't exist
|
||||
mkdir -p /var/lib/mongodb/keys
|
||||
|
||||
# Generate a random 756 byte key in base64 format
|
||||
openssl rand -base64 756 > /var/lib/mongodb/keys/mongo-keyfile
|
||||
|
||||
# Change the file owner to the mongodb user
|
||||
chown -R mongodb:mongodb /var/lib/mongodb/keys
|
||||
|
||||
# Set strict permissions: only the owner can read
|
||||
# (very important, otherwise Mongo won't start!)
|
||||
chmod 400 /var/lib/mongodb/keys/mongo-keyfile
|
||||
@@ -0,0 +1,3 @@
|
||||
## `/etc/mongod.conf`
|
||||
|
||||
You also need to make key, with `../keys.sh`
|
||||
@@ -0,0 +1,49 @@
|
||||
# mongod.conf
|
||||
|
||||
# for documentation of all options, see:
|
||||
# http://docs.mongodb.org/manual/reference/configuration-options/
|
||||
|
||||
# Where and how to store data.
|
||||
storage:
|
||||
dbPath: /var/lib/mongodb
|
||||
engine: wiredTiger
|
||||
wiredTiger:
|
||||
engineConfig:
|
||||
cacheSizeGB: 32
|
||||
|
||||
# where to write logging data.
|
||||
systemLog:
|
||||
destination: file
|
||||
logAppend: true
|
||||
path: /var/log/mongodb/mongod.log
|
||||
|
||||
# network interfaces
|
||||
net:
|
||||
port: 27017
|
||||
bindIp: 127.0.0.1,172.17.0.1
|
||||
|
||||
|
||||
# how the process runs
|
||||
processManagement:
|
||||
timeZoneInfo: /usr/share/zoneinfo
|
||||
|
||||
security:
|
||||
authorization: enabled
|
||||
keyFile: /var/lib/mongodb/keys/mongo-keyfile
|
||||
|
||||
setParameter:
|
||||
logicalSessionRefreshMillis: 900000
|
||||
localLogicalSessionTimeoutMinutes: 45
|
||||
|
||||
#operationProfiling:
|
||||
|
||||
#replication:
|
||||
replication:
|
||||
oplogSizeMB: 20480
|
||||
replSetName: rs0
|
||||
|
||||
#sharding:
|
||||
|
||||
## Enterprise-Only Options:
|
||||
|
||||
#auditLog:
|
||||
@@ -0,0 +1,102 @@
|
||||
# Multitenancy
|
||||
|
||||
TLDR a) and b)
|
||||
|
||||
## a) Oplog sockjs
|
||||
|
||||
- One server/Many WeKan domains/One IPv4
|
||||
- Multitenancy works
|
||||
|
||||
docker-compose.yml:
|
||||
```
|
||||
- METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
- DDP_TRANSPORT=uws
|
||||
```
|
||||
|
||||
## b) changeStreams uws
|
||||
|
||||
- One server/One WeKan domain/One IPv4
|
||||
|
||||
Multitenancy does not work, causes bug: round robin changing
|
||||
WeKan website at every webpage reload at same address.
|
||||
|
||||
For example:
|
||||
|
||||
1) At https://boards.wekan.team shows https://wekan.customer2.com
|
||||
2) Reload webpage. https://boards.wekan.team shows https://wekan.customer3.com
|
||||
3) Reload webpage. https://boards.wekan.team shows https://wekan.customer4.com
|
||||
- 4) Reload webpage. https://boards.wekan.team shows https://customer4.wekan.team
|
||||
- etc.
|
||||
|
||||
## Details
|
||||
|
||||
When multiple Meteor 3 applications are running on the same server using
|
||||
`network_mode: "host"` and are shared across the same MongoDB server,
|
||||
the combination `DDP_TRANSPORT=uws` and `METEOR_REACTIVITY_ORDER=changeStreams`
|
||||
is subject to a very rare but critical **Context/Descriptor Confusion**.
|
||||
|
||||
Here is a detailed technical explanation of what is happening behind the scenes and why it breaks a multi-tenant environment:
|
||||
|
||||
### 1. µWebSockets (`uws`) and shared host networking
|
||||
|
||||
`uws` is an extremely high-performance WebSocket engine written in C++.
|
||||
It bypasses many of the traditional networking layers of Node.js and
|
||||
handles network sockets and their file handles (File Descriptors)
|
||||
directly at a low level in its own Event Loop.
|
||||
|
||||
When containers are set to `network_mode: "host"`, they are not
|
||||
network-level isolated. They share the same Linux kernel
|
||||
network stack and memory space for network traffic.
|
||||
When your browser does a WebSocket handshake (`Upgrade: websocket`)
|
||||
after an HTTP request, `uws` allocates a memory address for the socket.
|
||||
|
||||
The bug occurs when multiple containers do this at the same time
|
||||
in the same host network environment,
|
||||
**the low-level C++ code in the `uws` engine can cross-identify socket identifiers from different processes**
|
||||
if the framework (in this case, an early beta integration of Meteor 3)
|
||||
is unable to keep connection ownership (v8 isolation) completely separate.
|
||||
|
||||
### 2. `changeStreams` and data misrouting
|
||||
|
||||
MongoDB's `changeStreams` relies on the application opening a long-lived,
|
||||
real-time listening pipe to the database. It is heavier and more complex
|
||||
than the old `oplog` table because it creates constantly changing,
|
||||
stateful cursors between the application and the database.
|
||||
|
||||
When this is combined with `uws` WebSocket:
|
||||
|
||||
1. User A opens the `boards.wekan.team` page.
|
||||
|
||||
2. The browser requests a WebSocket connection, and Meteor creates a
|
||||
`changeStream` listener on the database for User A's container.
|
||||
|
||||
3. At the same time, User B loads the `kanban.buetzow.de` page.
|
||||
|
||||
4. Since both containers talk locally to the same MongoDB process
|
||||
and share the same host interface, the application layer's
|
||||
reactivity engine (multiplexer) confuses which `changeStream`
|
||||
data input belongs to which active `uws` socket address.
|
||||
|
||||
**Result:** A reactive data packet from the database (e.g. different customer 1 boards)
|
||||
is pushed into the `uws` pipe that was locally open to the `boards.wekan.team` browser.
|
||||
The browser gets a perfectly valid HTML body, but the Javascript layer draws the data
|
||||
it received from the WebSocket.
|
||||
|
||||
### Why did good old `sockjs` and `oplog` fix the situation?
|
||||
|
||||
* **`sockjs`** is written in pure JavaScript (Node.js' native network stack).
|
||||
It strictly follows Node.js' own process boundaries, and does not perform
|
||||
direct C++-level optimizations beyond the kernel. Even though containers
|
||||
share the host network, Node.js makes sure that each process's sockets
|
||||
remain strictly within its own application window.
|
||||
|
||||
* **`oplog`** is a more passive way to read the database. The application
|
||||
reads the raw log file (`local.oplog.rs`) and filters its own data from
|
||||
there, instead of MongoDB's active `changeStream` engine trying to route
|
||||
and load balance cursors between different processes on the fly.
|
||||
|
||||
Simply put: `uws` and `changeStreams` are great tools when you are running
|
||||
**only one large application** on a single server for which you want
|
||||
maximum performance. In a multi-tenant environment
|
||||
(many different clients on the same machine), they are too aggressive
|
||||
and break the invisible boundaries between containers.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Loop through all subdirectories in the current directory
|
||||
for d in */ ; do
|
||||
# Remove the trailing slash to get just the folder name
|
||||
FOLDER_NAME="${d%/}"
|
||||
|
||||
echo "--------------------------------------------------"
|
||||
echo "Processing folder: $FOLDER_NAME"
|
||||
echo "--------------------------------------------------"
|
||||
|
||||
# Change into the subdirectory, skip if it fails
|
||||
cd "$FOLDER_NAME" || continue
|
||||
|
||||
# Run the Docker commands using the folder name
|
||||
docker compose down
|
||||
docker compose up -d --force-recreate
|
||||
#docker compose stop
|
||||
#docker compose start
|
||||
#docker rm "${FOLDER_NAME}-app"
|
||||
#docker compose up -d
|
||||
|
||||
# Return to the parent directory
|
||||
cd ..
|
||||
done
|
||||
|
||||
echo "All folders processed!"
|
||||
@@ -0,0 +1,10 @@
|
||||
# ========== CUSTOM END =====
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- wekan-files:/data:rw
|
||||
|
||||
volumes:
|
||||
wekan-files:
|
||||
driver: local
|
||||
@@ -0,0 +1,6 @@
|
||||
- MONGO_URL=mongodb://PARAMETER_user:PASSWORD@localhost:27017/els?authSource=els
|
||||
- MONGO_OPLOG_URL=mongodb://PARAMETER_user:PASSWORD@localhost:27017/local?replicaSet=rs0&authSource=els
|
||||
- MAIL_FROM=Customer WeKan Boards <boards@wekan.team>
|
||||
- MAIL_URL=smtp://AKIA-USERNAME:PASSWORD@email-smtp.eu-west-1.amazonaws.com:587?tls={ciphers:"SSLv3"}&secureConnection=false
|
||||
- PORT=3032
|
||||
- ROOT_URL=https://wekan.customer.com
|
||||
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
|
||||
PARAMETER:
|
||||
image: ghcr.io/wekan/wekan:v9.20
|
||||
container_name: PARAMETER-app
|
||||
restart: always
|
||||
network_mode: "host"
|
||||
environment:
|
||||
## See ../multitenancy.md about commented out settings:
|
||||
#- METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
|
||||
- METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
#- DDP_TRANSPORT=uws
|
||||
- DDP_TRANSPORT=sockjs
|
||||
- WRITABLE_PATH=/data
|
||||
- WITH_API=true
|
||||
- RICHER_CARD_COMMENT_EDITOR=false
|
||||
- CARD_OPENED_WEBHOOK_ENABLED=false
|
||||
- BIGEVENTS_PATTERN=NONE
|
||||
- NODE_FLAGS=--max-old-space-size=16384
|
||||
# ========== CUSTOM START =====
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check that the script was given exactly one parameter
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Error: Enter a file/database name as a parameter."
|
||||
echo "Usage: $0 <database_name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DB_PARAM=$1
|
||||
TARGET_FILE="${DB_PARAM}.txt"
|
||||
|
||||
# Check if the .txt file exists
|
||||
if [ ! -f "$TARGET_FILE" ]; then
|
||||
echo "Error: File '$TARGET_FILE' not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Display or retrieve MONGO_URL from the file.
|
||||
# 'grep' searches for the correct line, and
|
||||
# 'cut' cuts everything after the '=' sign.
|
||||
MONGO_URL=$(grep "^MONGO_URL=" "$TARGET_FILE" | cut -d'=' -f2-)
|
||||
|
||||
# Make sure that something was actually found
|
||||
# in the file and that it starts with mongodb://
|
||||
if [[ -z "$MONGO_URL" || ! "$MONGO_URL" =~ ^mongodb:// ]]; then
|
||||
echo "Error: No valid MONGO_URL line found in file $TARGET_FILE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Connecting to target: $DB_PARAM ---"
|
||||
|
||||
# Execute mongosh command with retrieved URL
|
||||
mongosh "$MONGO_URL"
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Save all setting of all Parallel Snaps that name start with wekan_ ,
|
||||
# for example wekan_customer1, wekan_customer2, etc
|
||||
# https://github.com/wekan/wekan/blob/main/docs/Platforms/FOSS/Snap/Many-Snaps-on-LXC.md
|
||||
|
||||
# Make sure the script is run as root (snap get often requires root privileges)
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: This script must be run as root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Looking for snap packages starting with wekan_... ---"
|
||||
|
||||
# List all installed snap packages and filter out those starting with "wekan_"
|
||||
# Extract only the package name (first column)
|
||||
SNAP_PACKAGES=$(snap list | awk 'NR>1 {print $1}' | grep '^wekan_')
|
||||
|
||||
if [ -z "$SNAP_PACKAGES" ]; then
|
||||
echo "No snap packages starting with 'wekan_' were found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Go through each package found
|
||||
for SNAP in $SNAP_PACKAGES; do
|
||||
OUTPUT_FILE="${SNAP}.txt"
|
||||
echo "Processing package: $SNAP -> Saving to file: $OUTPUT_FILE"
|
||||
# Empty the file if it already exists, or create a new one
|
||||
> "$OUTPUT_FILE"
|
||||
# Get the snap settings.
|
||||
# 'tail -n +2' omits the header line (Key / Value).
|
||||
# 'awk' separates the key and value at the first space/tab.
|
||||
snap get -l "$SNAP" | tail -n +2 | while read -r KEY VALUE; do
|
||||
# If the line is empty (e.g. an empty value), skip
|
||||
if [ -z "$KEY" ]; then
|
||||
continue
|
||||
fi
|
||||
# Change the key to UPPERCASE and replace dashes with underscores
|
||||
# e.g. "root-url" -> "ROOT_URL"
|
||||
CONVERTED_KEY=$(echo "$KEY" | tr '[:lower:]' '[:upper:]' | tr '-' '_')
|
||||
# Write the variable to the file in the format AVAIN=arvo
|
||||
echo "${CONVERTED_KEY}=${VALUE}" >> "$OUTPUT_FILE"
|
||||
done
|
||||
echo "Package $SNAP settings saved."
|
||||
done
|
||||
|
||||
echo "------------------------------------------------"
|
||||
echo "Done! All wekan_ package settings exported."
|
||||
echo "------------------------------------------------"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# UFW firewall.
|
||||
|
||||
# After setting up ssh public key login,
|
||||
# at /etc/ssh/sshd_config, only allow using SSH key,
|
||||
# with public key auth, and not password:
|
||||
# PermitRootLogin without-password
|
||||
|
||||
sudo apt-get -y install ssh ufw mosh
|
||||
|
||||
sudo ufw allow ssh
|
||||
sudo ufw allow http
|
||||
sudo ufw allow https
|
||||
sudo ufw allow mosh
|
||||
|
||||
sudo ufw enable
|
||||
|
||||
sudo ufw status numbered
|
||||
|
||||
# sudo ufw delete NUMBER-OF-RULE
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
sed -i "s|changeStreams,oplog,polling|oplog,polling|g" */docker-compose.yml
|
||||
sed -i "s|DDP_TRANSPORT=uws|DDP_TRANSPORT=sockjs|g" */docker-compose.yml
|
||||
|
||||
# - METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling
|
||||
# #- METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
# - DDP_TRANSPORT=uws
|
||||
# #- DDP_TRANSPORT=sockjs
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Loop through all subdirectories in the current directory
|
||||
for d in restore/*/ ; do
|
||||
# Remove the trailing slash to get just the folder name
|
||||
FOLDER_NAME="${d%/}"
|
||||
|
||||
echo "--------------------------------------------------"
|
||||
echo "Processing folder: $FOLDER_NAME"
|
||||
echo "--------------------------------------------------"
|
||||
|
||||
# Change into the subdirectory, skip if it fails
|
||||
cd "$FOLDER_NAME" || continue
|
||||
|
||||
# Run the Docker commands using the folder name
|
||||
docker compose down
|
||||
docker rm "${FOLDER_NAME}-app"
|
||||
docker compose up -d --force-recreate
|
||||
|
||||
# Return to the parent directory
|
||||
cd ..
|
||||
done
|
||||
|
||||
echo "All folders processed!"
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "Update WeKan old version to new version."
|
||||
echo "Example: ./update-wekan-version.sh 9.10 9.11"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Replace version number at docker-compose.yml of every user
|
||||
sed -i "s|ghcr.io/wekan/wekan:v$1|ghcr.io/wekan/wekan:v$2|g" restore/*/docker-compose.yml
|
||||
|
||||
# Loop through all subdirectories in the restore directory
|
||||
for d in restore/*/ ; do
|
||||
# Extract JUST the clean folder name (e.g., "parameter" instead of "restore/parameter")
|
||||
TMP="${d%/}"
|
||||
FOLDER_NAME="${TMP##*/}"
|
||||
|
||||
echo "--------------------------------------------------"
|
||||
echo "Processing folder: $FOLDER_NAME"
|
||||
echo "--------------------------------------------------"
|
||||
|
||||
# Run inside a subshell () so the main script never leaves the root directory
|
||||
(
|
||||
cd "$d" || exit 1
|
||||
|
||||
# Run the Docker commands using the clean folder name
|
||||
docker compose down
|
||||
docker rm "${FOLDER_NAME}-app" 2>/dev/null # 2>/dev/null ignores errors if it's already gone
|
||||
docker compose up -d --force-recreate
|
||||
)
|
||||
done
|
||||
|
||||
echo "All folders processed!"
|
||||
@@ -1,5 +1,18 @@
|
||||
## TODO
|
||||
|
||||
Kin 2.0 Meta OS:
|
||||
|
||||
- 2026-04-04:
|
||||
- Interview with Hogne "m0ns00n" Titlestad - From Amiga Demoscene to Building the Kin Meta OS
|
||||
- https://www.youtube.com/watch?v=_qgGqsn7qg4
|
||||
- Kin 2.0 Meta OS is full rewrite of Friend.
|
||||
- Older version is Kin Legacy
|
||||
- https://github.com/auraeinteractive
|
||||
- https://aurae.no/en/home
|
||||
- https://github.com/titlestad
|
||||
|
||||
## Old
|
||||
|
||||
WeKan:
|
||||
- integration to Friend TODO list https://github.com/FriendUPCloud/friendup/issues/114
|
||||
- timezone https://github.com/wekan/wekan/wiki/Timezone
|
||||
@@ -8,16 +21,6 @@ Friend Desktop Cloud OS:
|
||||
- Secure encrypted skinnable fast Open Source desktop in webbrowser/mobile/desktop app
|
||||
- WeKan as app at Friend
|
||||
|
||||
## Chat
|
||||
|
||||
Discord link at https://friendos.com/en/developers/
|
||||
|
||||
Not in use currently: IRC at Freenode #friendup
|
||||
|
||||
## Website
|
||||
|
||||
https://friendos.com
|
||||
|
||||
## Video
|
||||
|
||||
https://www.youtube.com/watch?v=SB4dNC7u2MU
|
||||
|
||||
@@ -104,10 +104,20 @@ sudo snap list
|
||||
```
|
||||
If not, change to candidate:
|
||||
```
|
||||
sudo snap refresh wekan_customer1 --channel=latest/candidate
|
||||
sudo snap refresh wekan_customer1 --channel=latest/candidate --amend
|
||||
```
|
||||
## Manual Parallel Snap
|
||||
|
||||
If using manual snap package, with Parallel Snap, that has name wekan_other:
|
||||
```
|
||||
sudo snap install --dangerous wekan-VERSION.snap --name wekan_other
|
||||
```
|
||||
Back to channel:
|
||||
```
|
||||
sudo snap refresh wekan_other --channel=latest/candidate --amend
|
||||
```
|
||||
If it complains about old database version (was at stable), stop it, move old files somewhere safe, and start again.
|
||||
WARNING: this deletes
|
||||
WARNING: this deletes stuff.
|
||||
```
|
||||
sudo su
|
||||
mkdir old_customer1_common
|
||||
@@ -333,16 +343,21 @@ files.company.com {
|
||||
|
||||
To server and LXC/LXD containers, install these packages:
|
||||
|
||||
Ubuntu:
|
||||
```
|
||||
sudo apt install libsquashfuse0 squashfuse fuse
|
||||
```
|
||||
|
||||
Fedora
|
||||
```
|
||||
sudo dnf install squashfuse fuse
|
||||
```
|
||||
Source: https://forum.snapcraft.io/t/system-does-not-fully-support-snapd/14767/11
|
||||
|
||||
## LXD init CIDR
|
||||
|
||||
IPv4 CIDR: 10.1.1.1/24
|
||||
|
||||
Ubuntu:
|
||||
```
|
||||
sudo apt install snapd
|
||||
|
||||
@@ -351,7 +366,28 @@ sudo reboot
|
||||
sudo snap install lxd
|
||||
|
||||
lxd init
|
||||
```
|
||||
Fedora:
|
||||
```
|
||||
sudo dnf install snapd
|
||||
|
||||
sudo reboot
|
||||
|
||||
sudo snap install lxd
|
||||
|
||||
# Any Fedora Linux or Fedora Linux Asahi Remix at Apple Silicon:
|
||||
# Internet access from LXD container:
|
||||
# 1) Allow LXD-bridge:
|
||||
sudo firewall-cmd --zone=trusted --add-interface=lxdbr0 --permanent
|
||||
sudo firewall-cmd --reload
|
||||
# 2) Allow IP Masquerading:
|
||||
sudo firewall-cmd --zone=public --add-masquerade --permanent
|
||||
sudo firewall-cmd --reload
|
||||
|
||||
lxd init
|
||||
```
|
||||
Answers for lxd init:
|
||||
```
|
||||
Would you like to use LXD clustering? (yes/no) [default=no]:
|
||||
Do you want to configure a new storage pool? (yes/no) [default=yes]:
|
||||
Name of the new storage pool [default=default]:
|
||||
@@ -418,7 +454,7 @@ Ubuntu Touch:
|
||||
```
|
||||
sudo su
|
||||
|
||||
snap install lxd --channel=5.21/stable
|
||||
snap install lxd
|
||||
```
|
||||
|
||||
3. From Ubuntu Touch Terminal, lxd init, etc.
|
||||
@@ -434,17 +470,17 @@ sudo lxc profile set default security.nesting true
|
||||
5. Start new lxc container
|
||||
|
||||
```
|
||||
# launch-ubu2504.sh
|
||||
# launch-ubu2604.sh
|
||||
# lxc launch image-name container-name
|
||||
lxc launch ubuntu:25.04 ubu2504
|
||||
lxc launch ubuntu:26.04 ubu2604
|
||||
```
|
||||
|
||||
6. Bash shell to inside of container:
|
||||
|
||||
```
|
||||
# into-ubu2504.sh
|
||||
# into-ubu2604.sh
|
||||
# lxc exec container-name -- /bin/bash
|
||||
lxc exec ubu2504 -- /bin/bash
|
||||
lxc exec ubu2604 -- /bin/bash
|
||||
```
|
||||
|
||||
## 3) Snapd and Wekan
|
||||
@@ -471,7 +507,7 @@ You can also add more lxc containers to different subdomains and proxy to them i
|
||||
|
||||
### New Ubuntu container
|
||||
```
|
||||
lxc launch images:ubuntu/20.04 lxccontainername
|
||||
lxc launch ubuntu:26.04 lxccontainername
|
||||
```
|
||||
### Inside LXC container
|
||||
```
|
||||
|
||||
Loaded 100 of 476 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user