Compare commits
662 Commits
master
...
add-some-t
Author | SHA1 | Date | |
---|---|---|---|
![]() |
319c7aa33a | ||
![]() |
5bec4c71ea | ||
![]() |
245dadc470 | ||
![]() |
08cde13fd4 | ||
![]() |
765456d233 | ||
![]() |
b6bc4a2d43 | ||
![]() |
851478bbcb | ||
![]() |
ff8e43631a | ||
![]() |
7b8a43b835 | ||
![]() |
bc0c186242 | ||
![]() |
d5b9981bf0 | ||
![]() |
7a8977a00c | ||
![]() |
5297e56eca | ||
![]() |
82ff610b89 | ||
![]() |
263f25303e | ||
![]() |
2aa7cdcb97 | ||
![]() |
e3e8f028f4 | ||
![]() |
f18523e0f2 | ||
![]() |
3533cbed93 | ||
![]() |
bf1cec2796 | ||
![]() |
7005d08c7e | ||
![]() |
e15b45bcf9 | ||
![]() |
603737b2ad | ||
![]() |
eebc7a4fab | ||
![]() |
3725b01fe5 | ||
![]() |
6c41aab463 | ||
![]() |
e8987607ff | ||
![]() |
9c440024bb | ||
![]() |
aa10df711e | ||
![]() |
51aca16e2f | ||
![]() |
4f1b9c2bfa | ||
![]() |
a801957194 | ||
![]() |
8f527eeb85 | ||
![]() |
92632f81f8 | ||
![]() |
e76809fb37 | ||
![]() |
03d142a47c | ||
![]() |
f589427b3c | ||
![]() |
16d78b577e | ||
![]() |
649450f7ee | ||
![]() |
a64705c28c | ||
![]() |
5d775ca08b | ||
![]() |
b2289e5776 | ||
![]() |
607abc003b | ||
![]() |
210623b39e | ||
![]() |
8c72dbbd9a | ||
![]() |
5779ce6301 | ||
![]() |
664573d65e | ||
![]() |
cfcc3f5a73 | ||
![]() |
8c303b7940 | ||
![]() |
785be9187d | ||
![]() |
69b04736d6 | ||
![]() |
4051631244 | ||
![]() |
b941b4c787 | ||
![]() |
45b5213c8c | ||
![]() |
3167b55430 | ||
![]() |
cc44b5581f | ||
![]() |
30615eaf02 | ||
![]() |
4e88f03aab | ||
![]() |
9bc2788560 | ||
![]() |
a65eb56098 | ||
![]() |
1c95995478 | ||
![]() |
d4c07980d5 | ||
![]() |
96946186c9 | ||
![]() |
742106071d | ||
![]() |
ce79eb330c | ||
![]() |
04d07f5eb3 | ||
![]() |
de7930f472 | ||
![]() |
2a66988807 | ||
![]() |
5bfa98d3a1 | ||
![]() |
ea41f929a6 | ||
![]() |
037dc09da6 | ||
![]() |
b9ad596f38 | ||
![]() |
c83e61af2c | ||
![]() |
2752716497 | ||
![]() |
64f9d8b48c | ||
![]() |
81fb008e59 | ||
![]() |
cede7c611b | ||
![]() |
8d66dca068 | ||
![]() |
26620966ab | ||
![]() |
29cb61bf47 | ||
![]() |
6b5863e257 | ||
![]() |
5a2c6ec326 | ||
![]() |
f52bce55a8 | ||
![]() |
76d3f42017 | ||
![]() |
5bfd330f08 | ||
![]() |
6d8ea12c5a | ||
![]() |
399b37f57e | ||
![]() |
e6f3d15b45 | ||
![]() |
75e4cfa0c4 | ||
![]() |
ce3337c46f | ||
![]() |
54b67b3b48 | ||
![]() |
61013e1875 | ||
![]() |
52d0d80816 | ||
![]() |
1fb58d3694 | ||
![]() |
8600154553 | ||
![]() |
dda348e270 | ||
![]() |
d2fd4f2114 | ||
![]() |
6f5dd89056 | ||
![]() |
0a00e0a474 | ||
![]() |
e63b2f54b7 | ||
![]() |
85f1d6d3ed | ||
![]() |
107d9f4e39 | ||
![]() |
4f488e5a39 | ||
![]() |
5a98844f1f | ||
![]() |
cabb9700a8 | ||
![]() |
d02324d14c | ||
![]() |
929d638f45 | ||
![]() |
19896cb7cf | ||
![]() |
e3d093c960 | ||
![]() |
59b4e8c2af | ||
![]() |
e08d3b6828 | ||
![]() |
0f03011def | ||
![]() |
6bdc9e35c4 | ||
![]() |
09bd471388 | ||
![]() |
4e90aac326 | ||
![]() |
ad40ee70e5 | ||
![]() |
b07050faf5 | ||
![]() |
0832c43d76 | ||
![]() |
e2d3c0792b | ||
![]() |
bac962c127 | ||
![]() |
738685c599 | ||
![]() |
d781268019 | ||
![]() |
218f0ba03c | ||
![]() |
f4084969b6 | ||
![]() |
a9fefa238c | ||
![]() |
75a408a804 | ||
![]() |
c7bdc0ce4f | ||
![]() |
b117864f04 | ||
![]() |
39996db2e0 | ||
![]() |
05b0864759 | ||
![]() |
a24b5b1914 | ||
![]() |
765f6d4da8 | ||
![]() |
386e1e8fb2 | ||
![]() |
d62985d1a7 | ||
![]() |
13afd4ef61 | ||
![]() |
c5647b9d39 | ||
![]() |
4220343b66 | ||
![]() |
bd7ab44654 | ||
![]() |
e7588b401a | ||
![]() |
24c60f7ff0 | ||
![]() |
f28e389336 | ||
![]() |
86a87e7727 | ||
![]() |
d9cf56a6ce | ||
![]() |
e398d518c2 | ||
![]() |
6d1856c222 | ||
![]() |
25be29910e | ||
![]() |
17e7891d0a | ||
![]() |
f427255554 | ||
![]() |
215a882281 | ||
![]() |
4103c65415 | ||
![]() |
d5cee75535 | ||
![]() |
d8173f160d | ||
![]() |
fd0cf318dd | ||
![]() |
978eeafeaa | ||
![]() |
9907c0e0e9 | ||
![]() |
7af2831be8 | ||
![]() |
28043dc2ca | ||
![]() |
52d0f936d7 | ||
![]() |
087f098b29 | ||
![]() |
2eaf5a8bf0 | ||
![]() |
7b1445ee4a | ||
![]() |
934424e6f1 | ||
![]() |
872f2fb7a2 | ||
![]() |
756d1c018c | ||
![]() |
a2c6482d68 | ||
![]() |
4d726ec97e | ||
![]() |
df618441b7 | ||
![]() |
0c9a6a6515 | ||
![]() |
8640d1357f | ||
![]() |
35eab88465 | ||
![]() |
f5ce8246a6 | ||
![]() |
b7b5392693 | ||
![]() |
f6050616bf | ||
![]() |
0d2cbf3554 | ||
![]() |
88923aaa2c | ||
![]() |
ae83cb0fdf | ||
![]() |
c678253af5 | ||
![]() |
a7a486790d | ||
![]() |
7894d58aef | ||
![]() |
23dbf6d144 | ||
![]() |
3d22e607d6 | ||
![]() |
5e384b72ff | ||
![]() |
313e1fc5f4 | ||
![]() |
4b8f9da72b | ||
![]() |
70ad60f198 | ||
![]() |
94c27bb399 | ||
![]() |
35e1f09d0f | ||
![]() |
91556561bb | ||
![]() |
e9d0a3f595 | ||
![]() |
1fe94d1b9e | ||
![]() |
188701f4f1 | ||
![]() |
1743142a8a | ||
![]() |
87afed97b9 | ||
![]() |
4404840b16 | ||
![]() |
ad8aa21dc2 | ||
![]() |
2eb0f804ee | ||
![]() |
73028da430 | ||
![]() |
9fd7290757 | ||
![]() |
f7f2b5f447 | ||
![]() |
71d281a59f | ||
![]() |
9111863889 | ||
![]() |
ddfb092c65 | ||
![]() |
d6a7db9540 | ||
![]() |
0158250a15 | ||
![]() |
b041141816 | ||
![]() |
02864a5625 | ||
![]() |
7656aa88ff | ||
![]() |
ddf584fec5 | ||
![]() |
260345ce79 | ||
![]() |
1d7bb4bde9 | ||
![]() |
fc320e3290 | ||
![]() |
3df050af5c | ||
![]() |
8088e06351 | ||
![]() |
4683eacf1c | ||
![]() |
46db0e5b3d | ||
![]() |
d495fa6052 | ||
![]() |
04ea1c4192 | ||
![]() |
c7e3c26819 | ||
![]() |
d910c7000a | ||
![]() |
fcff225cbd | ||
![]() |
092d8109b0 | ||
![]() |
a8f5f917dd | ||
![]() |
ee7aa12fd0 | ||
![]() |
201da84967 | ||
![]() |
341c9950f7 | ||
![]() |
233c50571b | ||
![]() |
c080f72062 | ||
![]() |
79c46c2f46 | ||
![]() |
0f4def5c46 | ||
![]() |
21e90f65e6 | ||
![]() |
a412cebe72 | ||
![]() |
ab179e3aa4 | ||
![]() |
75af43bb11 | ||
![]() |
a0607aa80d | ||
![]() |
03711516da | ||
![]() |
ce23c82de4 | ||
![]() |
9dda3b7836 | ||
![]() |
45c861a530 | ||
![]() |
a1dc9379c7 | ||
![]() |
373ba87de5 | ||
![]() |
21fac8576f | ||
![]() |
b5b39df212 | ||
![]() |
a658856724 | ||
![]() |
dd8ade8843 | ||
![]() |
ae8f4a9722 | ||
![]() |
d5100b57c7 | ||
![]() |
dc9da147a9 | ||
![]() |
dd0668daee | ||
![]() |
3fda89462d | ||
![]() |
4fa0ce111b | ||
![]() |
37c208a965 | ||
![]() |
cbebe2d8de | ||
![]() |
9d43a45c5c | ||
![]() |
fbb578392d | ||
![]() |
84c14f6de2 | ||
![]() |
b6b58e0f02 | ||
![]() |
9f764006af | ||
![]() |
57ab59514c | ||
![]() |
97103cdac3 | ||
![]() |
711cf243a7 | ||
![]() |
565877a173 | ||
![]() |
b3681ee007 | ||
![]() |
07ec25cb5f | ||
![]() |
0f80366f6c | ||
![]() |
32f53a6d72 | ||
![]() |
c22e6f0cb7 | ||
![]() |
672355c21c | ||
![]() |
187052e603 | ||
![]() |
6a1c4949d7 | ||
![]() |
9dc1af3dd1 | ||
![]() |
7c30d76f42 | ||
![]() |
5842ae5d6c | ||
![]() |
7d97ed90c9 | ||
![]() |
e05ca5ba0b | ||
![]() |
8360243e2b | ||
![]() |
65c1bff4b3 | ||
![]() |
3ef6dfdbdd | ||
![]() |
72ba4cded3 | ||
![]() |
817572c841 | ||
![]() |
5437fcb7ae | ||
![]() |
2d785d1fd9 | ||
![]() |
80a8d09b77 | ||
![]() |
6fe9be463c | ||
![]() |
04e8862128 | ||
![]() |
e394193abb | ||
![]() |
0024bf78d5 | ||
![]() |
fd67f7d7d7 | ||
![]() |
3325b0407f | ||
![]() |
3e9bfb605c | ||
![]() |
95ab9587c9 | ||
![]() |
b7e308fe85 | ||
![]() |
e272d0eb17 | ||
![]() |
1a6c6ffd10 | ||
![]() |
7f03f5423d | ||
![]() |
bcc73c59f8 | ||
![]() |
fcacd0f09f | ||
![]() |
586dd5cdfc | ||
![]() |
bbbaed259d | ||
![]() |
db77c22f9c | ||
![]() |
8680dbc268 | ||
![]() |
ea2b84c5dc | ||
![]() |
45c7027d3c | ||
![]() |
62017d4f4e | ||
![]() |
0d7dcdc284 | ||
![]() |
0eb3b77bc5 | ||
![]() |
e2fba4bbdd | ||
![]() |
6425a6bfc4 | ||
![]() |
42925e8a7c | ||
![]() |
75e89fe628 | ||
![]() |
f9c806d292 | ||
![]() |
3f5022d515 | ||
![]() |
0748f98d47 | ||
![]() |
3c8fd351a1 | ||
![]() |
40e6d80c35 | ||
![]() |
243cc40da4 | ||
![]() |
3bcc51d12c | ||
![]() |
2afd7d6737 | ||
![]() |
2357d63120 | ||
![]() |
e28d07ebd3 | ||
![]() |
33f960579c | ||
![]() |
6e14bd7959 | ||
![]() |
bdbc0df95b | ||
![]() |
5e10d94e5f | ||
![]() |
a934942bf6 | ||
![]() |
c440e88806 | ||
![]() |
45b7ec005b | ||
![]() |
c748523dbc | ||
![]() |
34eea8dff4 | ||
![]() |
87408c0e6d | ||
![]() |
5ad787075c | ||
![]() |
01ae5a411f | ||
![]() |
cde60a2aba | ||
![]() |
3f5808c67b | ||
![]() |
2c697b40dd | ||
![]() |
9453490bca | ||
![]() |
0baf6ad94d | ||
![]() |
df911c9e97 | ||
![]() |
2a5b59002e | ||
![]() |
44d801a2f5 | ||
![]() |
5ed5a88225 | ||
![]() |
1ca029fe94 | ||
![]() |
ddf3298b41 | ||
![]() |
e66c321ef9 | ||
![]() |
c215aa2f25 | ||
![]() |
e1afcd2678 | ||
![]() |
acd0d6d848 | ||
![]() |
9491c3ab14 | ||
![]() |
2e52d92fce | ||
![]() |
a360e38b7f | ||
![]() |
b72ace5adc | ||
![]() |
c56bd76c73 | ||
![]() |
28e4ea0e76 | ||
![]() |
b9de3044be | ||
![]() |
7df67b99a6 | ||
![]() |
f17a245839 | ||
![]() |
1e1b1b0e80 | ||
![]() |
0742c64c61 | ||
![]() |
992b3cd266 | ||
![]() |
f9a95882e9 | ||
![]() |
7ca78f9fc5 | ||
![]() |
95950dae86 | ||
![]() |
8e83204b34 | ||
![]() |
328c6b499d | ||
![]() |
af708f5d13 | ||
![]() |
7629e4b090 | ||
![]() |
c596d38d7a | ||
![]() |
c643bdcfce | ||
![]() |
630286a781 | ||
![]() |
71e90c12a7 | ||
![]() |
db6594f11f | ||
![]() |
7844987fe0 | ||
![]() |
9f2293f9c4 | ||
![]() |
fd4bf7ce5c | ||
![]() |
0dc969ce2c | ||
![]() |
f5455ef667 | ||
![]() |
edb8028162 | ||
![]() |
f415d637e9 | ||
![]() |
2c9103c769 | ||
![]() |
1c6f91d3a9 | ||
![]() |
05826e2ae1 | ||
![]() |
f6ebd71ac2 | ||
![]() |
49d0157c81 | ||
![]() |
ccfc5e4478 | ||
![]() |
66953dba3d | ||
![]() |
f4df8c070c | ||
![]() |
c3fa4daddb | ||
![]() |
b6782c64f7 | ||
![]() |
8a65621b35 | ||
![]() |
b8b0d46070 | ||
![]() |
2f73970e47 | ||
![]() |
ceacddf9c6 | ||
![]() |
4e89ad5f56 | ||
![]() |
1d4b4f97da | ||
![]() |
ac36a93203 | ||
![]() |
1dd0b242e2 | ||
![]() |
608d9fa9d8 | ||
![]() |
f5841c6a30 | ||
![]() |
266b13ce0b | ||
![]() |
1be9171b93 | ||
![]() |
037c2acee2 | ||
![]() |
38a0c9a9fc | ||
![]() |
8703cff7bf | ||
![]() |
687e3364b0 | ||
![]() |
149f5737dd | ||
![]() |
c2867e44c6 | ||
![]() |
f58562cd5b | ||
![]() |
92845a8640 | ||
![]() |
4fd18007de | ||
![]() |
005be8eec5 | ||
![]() |
402457640c | ||
![]() |
c3dd0facd2 | ||
![]() |
2fa5600d24 | ||
![]() |
c35416ec13 | ||
![]() |
9e6e31a728 | ||
![]() |
d7677af085 | ||
![]() |
dc0ba15828 | ||
![]() |
6527565a18 | ||
![]() |
c0a4004fd3 | ||
![]() |
97384d1ebb | ||
![]() |
41768eb157 | ||
![]() |
0c89493b58 | ||
![]() |
d5d1be794d | ||
![]() |
b2dc623448 | ||
![]() |
fa90a82745 | ||
![]() |
b55532e93e | ||
![]() |
880270bfe6 | ||
![]() |
7c21692a1e | ||
![]() |
3b69e78bb8 | ||
![]() |
a6caa0714e | ||
![]() |
83473dd3fa | ||
![]() |
aad90760a5 | ||
![]() |
cb853bf342 | ||
![]() |
8d45ae2886 | ||
![]() |
2e151cf198 | ||
![]() |
52c17bc5ca | ||
![]() |
d79d825ab4 | ||
![]() |
28b80d7fa2 | ||
![]() |
9735ffbc19 | ||
![]() |
f4d0e19d21 | ||
![]() |
14d1c904c0 | ||
![]() |
392fec3186 | ||
![]() |
ea0c7a73fd | ||
![]() |
21c2fdb5a6 | ||
![]() |
0a55fc6182 | ||
![]() |
64c4bd760b | ||
![]() |
e0c8fef365 | ||
![]() |
29b757ac75 | ||
![]() |
6eb0aaea88 | ||
![]() |
b42a5c3b69 | ||
![]() |
66447ac025 | ||
![]() |
747818af6a | ||
![]() |
a86fa93608 | ||
![]() |
f948318919 | ||
![]() |
5fea8de023 | ||
![]() |
20417a67b9 | ||
![]() |
cfd8238edc | ||
![]() |
eae00a6ccd | ||
![]() |
3731410486 | ||
![]() |
099e349a90 | ||
![]() |
de125d0d66 | ||
![]() |
eb8e8f80a9 | ||
![]() |
1229266546 | ||
![]() |
6620942928 | ||
![]() |
998d3817c6 | ||
![]() |
abca842eb9 | ||
![]() |
cb1314ab1d | ||
![]() |
dae6872fe8 | ||
![]() |
61b91adf25 | ||
![]() |
5f7f587477 | ||
![]() |
75b9aeea01 | ||
![]() |
fb71b5ed08 | ||
![]() |
2e8e5e73bc | ||
![]() |
befcd15298 | ||
![]() |
86d0fd04e6 | ||
![]() |
c28ed94ca0 | ||
![]() |
d139ab4a7d | ||
![]() |
6575a05a8a | ||
![]() |
3f2c3f2c0c | ||
![]() |
2c5ee7da10 | ||
![]() |
03bf4a5c5d | ||
![]() |
3fce37b735 | ||
![]() |
6c6ec227fa | ||
![]() |
128fea726e | ||
![]() |
97c44f85ef | ||
![]() |
ca2decda3a | ||
![]() |
e394a7e186 | ||
![]() |
c182f600f8 | ||
![]() |
915e3564f8 | ||
![]() |
1570ca5485 | ||
![]() |
b94b782547 | ||
![]() |
90328a5b19 | ||
![]() |
b42eb07a2f | ||
![]() |
131bfaaece | ||
![]() |
b759d5d5fb | ||
![]() |
003e9da792 | ||
![]() |
908271060b | ||
![]() |
08cd427084 | ||
![]() |
73a1d952b1 | ||
![]() |
1d5511421d | ||
![]() |
b925c63171 | ||
![]() |
6faebaf9df | ||
![]() |
c698f57395 | ||
![]() |
9adc13e722 | ||
![]() |
a04b24a5d5 | ||
![]() |
cd1ca7c039 | ||
![]() |
86b4987b7f | ||
![]() |
a7cf647669 | ||
![]() |
80d40d2fb5 | ||
![]() |
b19e961a35 | ||
![]() |
08f5f8ebd2 | ||
![]() |
1edf196049 | ||
![]() |
1f66b4d14a | ||
![]() |
cec5b0af9a | ||
![]() |
2b0e1e7c0e | ||
![]() |
a613a88232 | ||
![]() |
7afce71bef | ||
![]() |
fe4ba22a03 | ||
![]() |
eb35d9e122 | ||
![]() |
fa79fecc9d | ||
![]() |
61366b3792 | ||
![]() |
b55033d983 | ||
![]() |
b1f42988c8 | ||
![]() |
ef05b366c3 | ||
![]() |
b3d52e7d23 | ||
![]() |
d1c4250b46 | ||
![]() |
8d44077b24 | ||
![]() |
44e8b82412 | ||
![]() |
791ae2c869 | ||
![]() |
ee3753466a | ||
![]() |
b7dc703157 | ||
![]() |
2eca0dcc33 | ||
![]() |
d2219c1667 | ||
![]() |
132ea6f97f | ||
![]() |
28ef08de03 | ||
![]() |
865e53388e | ||
![]() |
3a105bb8c7 | ||
![]() |
edcda7a202 | ||
![]() |
edd69273e2 | ||
![]() |
444bfacbd9 | ||
![]() |
970e0a550f | ||
![]() |
b3ef68d66e | ||
![]() |
31382a42ee | ||
![]() |
1a7748d1ef | ||
![]() |
039b28cc5c | ||
![]() |
57eae3edb3 | ||
![]() |
dadc9416c0 | ||
![]() |
eab2342114 | ||
![]() |
9cf2f83936 | ||
![]() |
d823856873 | ||
![]() |
9b96c7cec8 | ||
![]() |
f211aba873 | ||
![]() |
c0fc2d8937 | ||
![]() |
a88b397748 | ||
![]() |
abc922c956 | ||
![]() |
4f2cd38344 | ||
![]() |
ca71038874 | ||
![]() |
be867b6b0d | ||
![]() |
f2f540b429 | ||
![]() |
7feec4ba07 | ||
![]() |
1d5fef1e53 | ||
![]() |
9c40efde17 | ||
![]() |
53dff66978 | ||
![]() |
3a595247e8 | ||
![]() |
33f5f60e31 | ||
![]() |
9dd2ea57c9 | ||
![]() |
fae7a696de | ||
![]() |
49e8b2d0f5 | ||
![]() |
281d908d3f | ||
![]() |
bacc42fe9c | ||
![]() |
1c26a28ca9 | ||
![]() |
d3a04cd132 | ||
![]() |
e6060a23ac | ||
![]() |
4b7fbf483a | ||
![]() |
bc3c30da92 | ||
![]() |
1896f770d1 | ||
![]() |
99d0b9ad72 | ||
![]() |
944afb9056 | ||
![]() |
23f5a24803 | ||
![]() |
60ae00ac14 | ||
![]() |
26862fa708 | ||
![]() |
853d14c1ce | ||
![]() |
11a5cac709 | ||
![]() |
0d021021df | ||
![]() |
19302466ef | ||
![]() |
0594259ae4 | ||
![]() |
ba485bbb18 | ||
![]() |
f5d6f24b30 | ||
![]() |
a42efe7eda | ||
![]() |
31d3712dbb | ||
![]() |
a15dad4a31 | ||
![]() |
9062a6afb1 | ||
![]() |
a79c3597d6 | ||
![]() |
470f1b5455 | ||
![]() |
46cc0e2fda | ||
![]() |
f3080e9468 | ||
![]() |
f8fe1166e2 | ||
![]() |
02bee92806 | ||
![]() |
b7f8e03894 | ||
![]() |
59bb4b4183 | ||
![]() |
bb53d53692 | ||
![]() |
52a2f1c936 | ||
![]() |
e328380c77 | ||
![]() |
a277ded227 | ||
![]() |
48d933e028 | ||
![]() |
24cf0a60bf | ||
![]() |
d5113aad72 | ||
![]() |
6957cb1f7d | ||
![]() |
66c706532a | ||
![]() |
332e98a095 | ||
![]() |
f639a1c9ff | ||
![]() |
8282bb0926 | ||
![]() |
264d2ff59d | ||
![]() |
90a6c7d8c2 | ||
![]() |
7de9166648 | ||
![]() |
0bf3440fc8 | ||
![]() |
cceae77529 | ||
![]() |
1474b46ff0 | ||
![]() |
01e3947b22 | ||
![]() |
e093bd8625 | ||
![]() |
390aab5ce7 | ||
![]() |
fb1a04971b | ||
![]() |
eff17a0929 | ||
![]() |
8eaaaa50ec | ||
![]() |
85b305d117 | ||
![]() |
4cbea6ffb6 | ||
![]() |
1967546ae4 | ||
![]() |
c9a3294852 | ||
![]() |
f1839387e2 | ||
![]() |
c8246a1d19 | ||
![]() |
3c6259cc88 | ||
![]() |
317df5af0f | ||
![]() |
2142770a5b | ||
![]() |
51297b49fc | ||
![]() |
311cc2a1f4 | ||
![]() |
6bc760a6fa | ||
![]() |
50c96cfd77 | ||
![]() |
7747dc7f28 | ||
![]() |
678867e981 | ||
![]() |
4a4fae104e | ||
![]() |
265b5157af | ||
![]() |
b5b593945d | ||
![]() |
15c8a30c92 | ||
![]() |
40c6eb0b95 | ||
![]() |
4c4e3a07f6 | ||
![]() |
35322fa01d | ||
![]() |
88e88fb6b9 | ||
![]() |
7016e5a349 | ||
![]() |
c7e275d4ec | ||
![]() |
b1d3340fce | ||
![]() |
2ae5930578 | ||
![]() |
9dd6940383 | ||
![]() |
e2194f5c49 | ||
![]() |
e6dcbcb052 | ||
![]() |
5acf3042c3 | ||
![]() |
7286abaaae | ||
![]() |
0d5e95fa7c | ||
![]() |
c4b54c40fb | ||
![]() |
ec0b980e06 | ||
![]() |
94ccd3ad36 | ||
![]() |
49d67f9130 | ||
![]() |
2bb846734e | ||
![]() |
056d9c6b4c |
@@ -1,13 +1 @@
|
|||||||
# Exclude a bunch of stuff which can make the build context a larger than it needs to be
|
/testdata
|
||||||
tests/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
lib/
|
|
||||||
node_modules/
|
|
||||||
electron_app/
|
|
||||||
karma-reports/
|
|
||||||
.pnp.cjs
|
|
||||||
.pnp.loader.mjs
|
|
||||||
.idea/
|
|
||||||
.tmp/
|
|
||||||
config.json*
|
|
||||||
|
63
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Contribution Guidelines
|
||||||
|
|
||||||
|
Table of Contents:
|
||||||
|
|
||||||
|
<!-- vim-markdown-toc GFM -->
|
||||||
|
|
||||||
|
* [Did you find a bug?](#did-you-find-a-bug)
|
||||||
|
* [Is it a Security Vulnerability?](#is-it-a-security-vulnerability)
|
||||||
|
* [Is it already a known issue?](#is-it-already-a-known-issue)
|
||||||
|
* [Reporting a Bug](#reporting-a-bug)
|
||||||
|
* [Is there a patch for the bug?](#is-there-a-patch-for-the-bug)
|
||||||
|
* [Do you want to add a new feature?](#do-you-want-to-add-a-new-feature)
|
||||||
|
* [Is it just an idea?](#is-it-just-an-idea)
|
||||||
|
* [Is there a patch for the feature?](#is-there-a-patch-for-the-feature)
|
||||||
|
* [Do you have questions about the Synapse Admin project or need guidance?](#do-you-have-questions-about-the-synapse-admin-project-or-need-guidance)
|
||||||
|
|
||||||
|
<!-- vim-markdown-toc -->
|
||||||
|
|
||||||
|
## Did you find a bug?
|
||||||
|
|
||||||
|
### Is it a Security Vulnerability?
|
||||||
|
|
||||||
|
Please follow the [Security Policy](https://github.com/etkecc/synapse-admin/blob/main/.github/SECURITY.md) for reporting
|
||||||
|
security vulnerabilities.
|
||||||
|
|
||||||
|
### Is it already a known issue?
|
||||||
|
|
||||||
|
Please ensure the bug was not already reported by searching [the Issues section](https://github.com/etkecc/synapse-admin/issues).
|
||||||
|
|
||||||
|
### Reporting a Bug
|
||||||
|
|
||||||
|
If you think you have found a bug in Synapse Admin, it is not a security vulnerability, and it is not already reported,
|
||||||
|
please open [a new issue](https://github.com/etkecc/synapse-admin/issues/new) with:
|
||||||
|
* A proper title and clear description of the problem.
|
||||||
|
* As much relevant information as possible:
|
||||||
|
* The version of Synapse Admin you are using.
|
||||||
|
* The version of Synapse you are using.
|
||||||
|
* Any relevant browser console logs, failed requests details, and error messages.
|
||||||
|
|
||||||
|
### Is there a patch for the bug?
|
||||||
|
|
||||||
|
If you already have a patch for the bug, please open a pull request with the patch,
|
||||||
|
and mention the issue number in the pull request description.
|
||||||
|
|
||||||
|
## Do you want to add a new feature?
|
||||||
|
|
||||||
|
### Is it just an idea?
|
||||||
|
|
||||||
|
Please open [a new issue](https://github.com/etkecc/synapse-admin/issues/new) with:
|
||||||
|
* A proper title and clear description of the requested feature.
|
||||||
|
* Any relevant information about the feature:
|
||||||
|
* Why do you think this feature is needed?
|
||||||
|
* How do you think it should work? (provide Synapse Admin API endpoint)
|
||||||
|
* Any relevant screenshots or mockups.
|
||||||
|
|
||||||
|
### Is there a patch for the feature?
|
||||||
|
|
||||||
|
If you already have a patch for the feature, please open a pull request with the patch,
|
||||||
|
and mention the issue number in the pull request description.
|
||||||
|
|
||||||
|
## Do you have questions about the Synapse Admin project or need guidance?
|
||||||
|
|
||||||
|
Please use the official community Matrix room: [#synapse-admin:etke.cc](https://matrix.to/#/#synapse-admin:etke.cc)
|
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
liberapay: etkecc
|
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Report a Synapse Admin bug
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Browser console logs**
|
||||||
|
If applicable, add the browser console's log
|
||||||
|
|
||||||
|
**Instance configuration:**
|
||||||
|
- Synapse Admin version: [e.g. v0.10.3-etke39]
|
||||||
|
- Synapse version [v1.127.1]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for Synapse Admin
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Provide related Synapse Admin API endpoints**
|
||||||
|
If applicable, provide links to the Synapse Admin API's endpoints that could be used to implement that feature
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
14
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Only [the last published version](https://github.com/etkecc/synapse-admin/releases/latest) of the project is supported.
|
||||||
|
This means that only the latest version will receive security updates.
|
||||||
|
If you are using an older version, you are strongly encouraged to upgrade to the latest version.
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please contact us using the [#synapse-admin:etke.cc](https://matrix.to/#/#synapse-admin:etke.cc) Matrix room.
|
||||||
|
The Synapse Admin project is a static JS UI for the Synapse server,
|
||||||
|
so it is unlikely that there are (or will be) any impactful security vulnerabilities in the project itself.
|
||||||
|
However, we do not rule out the possibility of such cases, so we will be happy to receive any reports!
|
7
.github/dependabot.yml
vendored
@@ -4,17 +4,16 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
ignore:
|
open-pull-requests-limit: 30
|
||||||
# Major updates for react-admin have breaking changes
|
|
||||||
- dependency-name: "react-admin"
|
|
||||||
update-types: ["version-update:semver-major"]
|
|
||||||
|
|
||||||
- package-ecosystem: "docker"
|
- package-ecosystem: "docker"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 30
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 30
|
||||||
|
26
.github/workflows/build-test.yml
vendored
@@ -1,26 +0,0 @@
|
|||||||
name: build-test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["master"]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "18"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn --immutable
|
|
||||||
- name: Run checks
|
|
||||||
run: yarn lint
|
|
||||||
- name: Run tests
|
|
||||||
run: yarn test
|
|
63
.github/workflows/docker-release.yml
vendored
@@ -1,63 +0,0 @@
|
|||||||
name: Create docker image(s) and push to docker hub and ghcr.io
|
|
||||||
# see https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-docker-hub-and-github-packages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
# Sequence of patterns matched against refs/heads
|
|
||||||
# prettier-ignore
|
|
||||||
branches:
|
|
||||||
# Push events on master branch
|
|
||||||
- master
|
|
||||||
# Sequence of patterns matched against refs/tags
|
|
||||||
tags:
|
|
||||||
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
docker:
|
|
||||||
name: Push Docker image to multiple registries
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
awesometechnologies/synapse-admin
|
|
||||||
ghcr.io/${{ github.repository }}
|
|
||||||
|
|
||||||
- name: Build and Push Tag
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
31
.github/workflows/edge_ghpage.yml
vendored
@@ -1,31 +0,0 @@
|
|||||||
name: Build and Deploy Edge version to GH Pages
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout 🛎️
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 100
|
|
||||||
fetch-tags: true
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- name: Install and Build 🔧
|
|
||||||
run: |
|
|
||||||
yarn install --immutable
|
|
||||||
yarn build --base=/synapse-admin
|
|
||||||
|
|
||||||
- name: Deploy 🚀
|
|
||||||
uses: JamesIves/github-pages-deploy-action@v4.6.8
|
|
||||||
with:
|
|
||||||
branch: gh-pages
|
|
||||||
folder: dist
|
|
30
.github/workflows/github-release.yml
vendored
@@ -1,30 +0,0 @@
|
|||||||
name: Create release tarball and attach to tag
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- run: yarn install --immutable
|
|
||||||
- run: yarn build
|
|
||||||
- run: |
|
|
||||||
version=`git describe --dirty --tags || echo unknown`
|
|
||||||
cp -r dist synapse-admin-$version
|
|
||||||
tar chvzf dist/synapse-admin-$version.tar.gz synapse-admin-$version
|
|
||||||
- uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191
|
|
||||||
with:
|
|
||||||
files: dist/*.tar.gz
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
121
.github/workflows/workflow.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
tags: [ "v*" ]
|
||||||
|
env:
|
||||||
|
bunny_version: v0.1.0
|
||||||
|
base_path: ./
|
||||||
|
permissions:
|
||||||
|
checks: write
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
pull-requests: read
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
cache: yarn
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable --network-timeout=300000
|
||||||
|
- name: Build
|
||||||
|
run: yarn build --base=${{ env.base_path }}
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist/
|
||||||
|
name: dist
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
compression-level: 0
|
||||||
|
overwrite: true
|
||||||
|
include-hidden-files: true
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Docker
|
||||||
|
needs: build
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||||
|
- name: Login to ghcr.io
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Login to hub.docker.com
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
username: etkecc
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ github.repository }}
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
registry.etke.cc/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||||
|
type=semver,pattern={{raw}}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
cdn:
|
||||||
|
name: CDN
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
- name: Upload
|
||||||
|
run: |
|
||||||
|
wget -O bunny-upload.tar.gz https://github.com/etkecc/bunny-upload/releases/download/${{ env.bunny_version }}/bunny-upload_Linux_x86_64.tar.gz
|
||||||
|
tar -xzf bunny-upload.tar.gz
|
||||||
|
echo "${{ secrets.BUNNY_CONFIG }}" > bunny-config.yaml
|
||||||
|
./bunny-upload -c bunny-config.yaml
|
||||||
|
|
||||||
|
github-release:
|
||||||
|
name: Github Release
|
||||||
|
needs: build
|
||||||
|
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
- name: Prepare release
|
||||||
|
run: |
|
||||||
|
mv dist synapse-admin
|
||||||
|
tar chvzf synapse-admin.tar.gz synapse-admin
|
||||||
|
- uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2
|
||||||
|
with:
|
||||||
|
files: synapse-admin.tar.gz
|
||||||
|
generate_release_notes: true
|
||||||
|
make_latest: "true"
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
3
.gitignore
vendored
@@ -191,3 +191,6 @@ sketch
|
|||||||
# .pnp.*
|
# .pnp.*
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
|
# End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
|
||||||
|
|
||||||
|
/testdata/synapse.data
|
||||||
|
/testdata/postgres.data
|
||||||
|
2
.vscode/settings.json
vendored
@@ -5,6 +5,6 @@
|
|||||||
},
|
},
|
||||||
"eslint.nodePath": ".yarn/sdks",
|
"eslint.nodePath": ".yarn/sdks",
|
||||||
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
|
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
|
||||||
"typescript.tsdk": ".yarn/sdks/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
}
|
}
|
||||||
|
5
.watchmanconfig
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"ignore_dirs": [
|
||||||
|
"testdata"
|
||||||
|
]
|
||||||
|
}
|
@@ -1 +0,0 @@
|
|||||||
yarnPath: .yarn/releases/yarn-4.4.1.cjs
|
|
27
Dockerfile
@@ -1,26 +1,5 @@
|
|||||||
# Builder
|
FROM ghcr.io/static-web-server/static-web-server:2
|
||||||
FROM node:lts as builder
|
|
||||||
LABEL org.opencontainers.image.url=https://github.com/Awesome-Technologies/synapse-admin org.opencontainers.image.source=https://github.com/Awesome-Technologies/synapse-admin
|
|
||||||
# Base path for synapse admin
|
|
||||||
ARG BASE_PATH=./
|
|
||||||
|
|
||||||
WORKDIR /src
|
ENV SERVER_ROOT=/app
|
||||||
|
|
||||||
# Copy .yarn directory to the working directory (must be on a separate line!)
|
COPY ./dist /app
|
||||||
# Use https://docs.docker.com/engine/reference/builder/#copy---parents when available
|
|
||||||
COPY .yarn .yarn
|
|
||||||
COPY package.json .yarnrc.yml yarn.lock ./
|
|
||||||
|
|
||||||
# Disable telemetry and install packages
|
|
||||||
RUN yarn config set enableTelemetry 0 && yarn install --immutable --network-timeout=300000
|
|
||||||
|
|
||||||
COPY . /src
|
|
||||||
RUN yarn build --base=$BASE_PATH
|
|
||||||
|
|
||||||
# App
|
|
||||||
FROM nginx:stable-alpine
|
|
||||||
|
|
||||||
COPY --from=builder /src/dist /app
|
|
||||||
|
|
||||||
RUN rm -rf /usr/share/nginx/html \
|
|
||||||
&& ln -s /app /usr/share/nginx/html
|
|
||||||
|
11
Dockerfile.build
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:lts AS builder
|
||||||
|
ARG BASE_PATH=./
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . /src
|
||||||
|
RUN yarn config set enableTelemetry 0 && \
|
||||||
|
yarn install --immutable --network-timeout=300000 && \
|
||||||
|
yarn build --base=$BASE_PATH
|
||||||
|
|
||||||
|
FROM ghcr.io/static-web-server/static-web-server:2
|
||||||
|
ENV SERVER_ROOT=/app
|
||||||
|
COPY --from=builder /src/dist /app
|
280
README.md
@@ -1,19 +1,212 @@
|
|||||||
[](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
<p align="center">
|
||||||
[](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
|
<img alt="Synapse Admin Logo" src="./public/images/logo.webp" height="140" />
|
||||||
[](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
|
<h3 align="center">
|
||||||
[](https://awesome-technologies.github.io/synapse-admin/)
|
Synapse Admin<br>
|
||||||
[](https://hub.docker.com/r/awesometechnologies/synapse-admin)
|
<a href="https://matrix.to/#/#synapse-admin:etke.cc">
|
||||||
[](https://github.com/Awesome-Technologies/synapse-admin/releases)
|
<img alt="Community room" src="https://img.shields.io/badge/room-community_room-green?logo=matrix&label=%23synapse-admin%3Aetke.cc">
|
||||||
|
</a><br>
|
||||||
|
<a href="./LICENSE">
|
||||||
|
<img alt="License" src="https://img.shields.io/github/license/etkecc/synapse-admin">
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<p align="center">Feature-packed and visually customizable: A better way to manage your Synapse homeserver.</p>
|
||||||
|
</p>
|
||||||
|
|
||||||
# Synapse admin ui
|
---
|
||||||
|
|
||||||
This project is built using [react-admin](https://marmelab.com/react-admin/).
|

|
||||||
|

|
||||||
|
|
||||||
|
<!-- vim-markdown-toc GFM -->
|
||||||
|
|
||||||
|
* [Fork differences](#fork-differences)
|
||||||
|
* [Changes](#changes)
|
||||||
|
* [exclusive for etke.cc customers](#exclusive-for-etkecc-customers)
|
||||||
|
* [Development](#development)
|
||||||
|
* [Support](#support)
|
||||||
|
* [Configuration](#configuration)
|
||||||
|
* [Prefilling login form](#prefilling-login-form)
|
||||||
|
* [Restricting available homeserver](#restricting-available-homeserver)
|
||||||
|
* [Configuring CORS credentials](#configuring-cors-credentials)
|
||||||
|
* [Protecting appservice managed users](#protecting-appservice-managed-users)
|
||||||
|
* [Adding custom menu items](#adding-custom-menu-items)
|
||||||
|
* [Usage](#usage)
|
||||||
|
* [Supported Synapse](#supported-synapse)
|
||||||
|
* [Prerequisites](#prerequisites)
|
||||||
|
* [Use without install](#use-without-install)
|
||||||
|
* [Step-By-Step install](#step-by-step-install)
|
||||||
|
* [Steps for 1)](#steps-for-1)
|
||||||
|
* [Steps for 2)](#steps-for-2)
|
||||||
|
* [Steps for 3)](#steps-for-3)
|
||||||
|
* [Serving Synapse Admin on a different path](#serving-synapse-admin-on-a-different-path)
|
||||||
|
* [Development](#development-1)
|
||||||
|
|
||||||
|
<!-- vim-markdown-toc -->
|
||||||
|
|
||||||
|
## Fork differences
|
||||||
|
|
||||||
|
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream,
|
||||||
|
this fork introduces numerous enhancements to improve usability and extend functionality,
|
||||||
|
including support for authenticated media, advanced user management options, and visual customization.
|
||||||
|
The full list is described below in the [Changes](#changes) section.
|
||||||
|
|
||||||
|
**Availability**
|
||||||
|
|
||||||
|
* As a core/default component on [etke.cc](https://etke.cc/?utm_source=github&utm_medium=readme&utm_campaign=synapse-admin)
|
||||||
|
* As a standalone app on [admin.etke.cc](https://admin.etke.cc)
|
||||||
|
* As a prebuilt distribution on [GitHub Releases](https://github.com/etkecc/synapse-admin/releases)
|
||||||
|
* As a Docker container on [Docker Hub](https://hub.docker.com/r/etkecc/synapse-admin) and [GitHub Container Registry](https://github.com/etkecc/synapse-admin/pkgs/container/synapse-admin)
|
||||||
|
* As a component in [Matrix-Docker-Ansible-Deploy Playbook](https://github.com/spantaleev/matrix-docker-ansible-deploy/blob/master/docs/configuring-playbook-synapse-admin.md)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
_the list will be updated as new changes are added_
|
||||||
|
|
||||||
|
The following changes are already implemented:
|
||||||
|
|
||||||
|
* 🛑 [Prevent admins from deleting themselves](https://github.com/etkecc/synapse-admin/pull/1)
|
||||||
|
* 🐛 [Fix user's default tab not being shown](https://github.com/etkecc/synapse-admin/pull/8)
|
||||||
|
* 🔑 [Add identifier when authorizing with password](https://github.com/Awesome-Technologies/synapse-admin/pull/601)
|
||||||
|
* 🔒 [Add ability to toggle whether to show locked users](https://github.com/Awesome-Technologies/synapse-admin/pull/573)
|
||||||
|
* 🖊️ [Fix user's display name in header on user's page](https://github.com/etkecc/synapse-admin/pull/9)
|
||||||
|
* 🧹 [Fix footer overlapping content](https://github.com/Awesome-Technologies/synapse-admin/issues/574)
|
||||||
|
* 🐋 Switch from nginx to [SWS](https://static-web-server.net/) for serving the app, reducing the size of the Docker image
|
||||||
|
* 🔄 [Fix redirect URL after user creation](https://github.com/etkecc/synapse-admin/pull/16)
|
||||||
|
* 🔍 [Display actual Synapse errors](https://github.com/etkecc/synapse-admin/pull/17)
|
||||||
|
* ⚠️ [Fix base_url being undefined on unsuccessful login](https://github.com/etkecc/synapse-admin/pull/18)
|
||||||
|
* 📜 [Put the version into manifest.json](https://github.com/Awesome-Technologies/synapse-admin/issues/507) (later replaced with a proper manifest.json generation on build)
|
||||||
|
* 📊 [Federation page improvements](https://github.com/Awesome-Technologies/synapse-admin/pull/583) (using icons)
|
||||||
|
* 🚪 [Add UI option to block deleted rooms from being rejoined](https://github.com/etkecc/synapse-admin/pull/26)
|
||||||
|
* 🛠️ [Fix required fields check on Bulk registration CSV upload](https://github.com/etkecc/synapse-admin/pull/32)
|
||||||
|
* 🛡️ [Fix requests with invalid MXIDs on Bulk registration](https://github.com/etkecc/synapse-admin/pull/33)
|
||||||
|
* 🖼️ [Expose user avatar URL field in the UI](https://github.com/etkecc/synapse-admin/pull/27)
|
||||||
|
* 🚀 [Upgrade react-admin to v5](https://github.com/etkecc/synapse-admin/pull/40)
|
||||||
|
* 🔒 [Restrict actions on specific users](https://github.com/etkecc/synapse-admin/pull/42)
|
||||||
|
* 📞 [Add `Contact support` menu item](https://github.com/etkecc/synapse-admin/pull/45)
|
||||||
|
* 🧹 [Provide options to delete media and redact events on user erase](https://github.com/etkecc/synapse-admin/pull/49)
|
||||||
|
* 🎞️ [Authenticated Media support](https://github.com/etkecc/synapse-admin/pull/51)
|
||||||
|
* 👁️ [Better media preview/download](https://github.com/etkecc/synapse-admin/pull/53)
|
||||||
|
* 🔐 [Login with access token](https://github.com/etkecc/synapse-admin/pull/58)
|
||||||
|
* 📏 [Fix footer causing vertical scrollbar](https://github.com/etkecc/synapse-admin/pull/60)
|
||||||
|
* 🍴 [Custom Menu Items](https://github.com/etkecc/synapse-admin/pull/79)
|
||||||
|
* 🧑💻 [Add user profile to the top menu](https://github.com/etkecc/synapse-admin/pull/80)
|
||||||
|
* 🎨 [Enable visual customization](https://github.com/etkecc/synapse-admin/pull/81)
|
||||||
|
* 🛋️ [Fix room state events display](https://github.com/etkecc/synapse-admin/pull/100)
|
||||||
|
* 🧹 [Sanitize CSV on import](https://github.com/etkecc/synapse-admin/pull/101)
|
||||||
|
* ⚙️ Allow setting version using `SYNAPSE_ADMIN_VERSION` environment variable on build (if git is not available)
|
||||||
|
* 🧪 [Add option to control user's experimental features](https://github.com/etkecc/synapse-admin/pull/111)
|
||||||
|
* 🔑 [Add random password generation on user create/edit form](https://github.com/etkecc/synapse-admin/pull/123)
|
||||||
|
* 🚦 [Add option to set user's rate limits](https://github.com/etkecc/synapse-admin/pull/125)
|
||||||
|
* 🌐 [Support configuration via /.well-known/matrix/client](https://github.com/etkecc/synapse-admin/pull/126)
|
||||||
|
* 🛑 [Prevent accidental user overwrites](https://github.com/etkecc/synapse-admin/pull/139)
|
||||||
|
* 🔍 [Allow providing login form details via GET params](https://github.com/etkecc/synapse-admin/pull/140)
|
||||||
|
* 🎨 [Add preferred theme colors to login page and footer](https://github.com/etkecc/synapse-admin/pull/155)
|
||||||
|
* 🛡️ [Add "Assign Admin" button to the rooms](https://github.com/etkecc/synapse-admin/pull/156)
|
||||||
|
* 🖼️ [Add rooms' avatars](https://github.com/etkecc/synapse-admin/pull/158)
|
||||||
|
* 🏷️ [User Badges](https://github.com/etkecc/synapse-admin/pull/160)
|
||||||
|
* 🔄 [Allow prefilling any fields on the login form via GET params](https://github.com/etkecc/synapse-admin/pull/181)
|
||||||
|
* 🎞️ [Add "Media" tab for rooms](https://github.com/etkecc/synapse-admin/pull/196)
|
||||||
|
* 📞 [Support E.164-based Matrix IDs (MSC4009)](https://github.com/etkecc/synapse-admin/pull/214)
|
||||||
|
* 🛑 [Add support for Account Suspension (MSC3823)](https://github.com/etkecc/synapse-admin/pull/195)
|
||||||
|
* 🗑️ [Add "Purge Remote Media" button](https://github.com/etkecc/synapse-admin/pull/237)
|
||||||
|
* 📁 [Respect base url (`BASE_PATH` / `vite build --base`) when loading `config.json`](https://github.com/etkecc/synapse-admin/pull/274)
|
||||||
|
* 🗂️ [Add Users' Account Data tab](https://github.com/etkecc/synapse-admin/pull/276)
|
||||||
|
* 🧾 [Make bulk registration CSV import more user-friendly](https://github.com/etkecc/synapse-admin/pull/411)
|
||||||
|
* 🌐 [Configurable CORS Credentials](https://github.com/etkecc/synapse-admin/pull/456)
|
||||||
|
|
||||||
|
#### exclusive for [etke.cc](https://etke.cc) customers
|
||||||
|
|
||||||
|
We at [etke.cc](https://etke.cc) attempting to develop everything open-source, but some things are too specific to be used by anyone else.
|
||||||
|
The following list contains such features - they are only available for [etke.cc](https://etke.cc) customers.
|
||||||
|
|
||||||
|
* 📊 [Server Status indicator and page](https://github.com/etkecc/synapse-admin/pull/182)
|
||||||
|
* 📬 [Server Notifications indicator and page](https://github.com/etkecc/synapse-admin/pull/240)
|
||||||
|
* 🛠️ [Server Commands panel](https://github.com/etkecc/synapse-admin/pull/365)
|
||||||
|
* 🚀 [Server Actions page](https://github.com/etkecc/synapse-admin/pull/457)
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
`just run-dev` to start the development stack (depending on your system speed, you may want to re-run this command if
|
||||||
|
user creation fails)
|
||||||
|
|
||||||
|
This command initializes the development environment (local Synapse server, Element Web client app, and Postgres DB),
|
||||||
|
and launches the app in a dev mode at `http://localhost:5173`
|
||||||
|
|
||||||
|
After that open [http://localhost:5173](http://localhost:5173?username=admin&password=admin&server=http://localhost:8008) in your browser,
|
||||||
|
login using the following credentials:
|
||||||
|
|
||||||
|
* Login: admin
|
||||||
|
* Password: admin
|
||||||
|
* Homeserver URL: http://localhost:8008
|
||||||
|
|
||||||
|
Element Web runs on http://localhost:8080
|
||||||
|
|
||||||
|
### Support
|
||||||
|
|
||||||
|
If you have any questions or need help, feel free to join the [community room](https://matrix.to/#/#synapse-admin:etke.cc) or create an issue on GitHub.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
You can use `config.json` file to configure Synapse Admin instance,
|
||||||
|
and `/.well-known/matrix/client` file to provide Synapse Admin configuration specifically for your homeserver.
|
||||||
|
In the latter case, any instance of Synapse Admin will automatically pick up the configuration from the homeserver.
|
||||||
|
Note that configuration inside the `/.well-known/matrix/client` file should go under the `cc.etke.synapse-admin` key,
|
||||||
|
and it will override the configuration from the `config.json` file.
|
||||||
|
|
||||||
|
In case you use [spantaleev/matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy) or
|
||||||
|
[etkecc/ansible](https://github.com/etkecc/ansible),
|
||||||
|
configuration will be automatically added to the `/.well-known/matrix/client` file.
|
||||||
|
|
||||||
|
[Configuration options](./docs/config.md)
|
||||||
|
|
||||||
|
The `config.json` can be injected into a Docker container using a bind mount.
|
||||||
|
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
synapse-admin:
|
||||||
|
...
|
||||||
|
volumes:
|
||||||
|
./config.json:/app/config.json:ro
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prefilling login form
|
||||||
|
|
||||||
|
You can prefill all fields on the login page using GET parameters.
|
||||||
|
|
||||||
|
[Documentation](./docs/prefill-login-form.md)
|
||||||
|
|
||||||
|
|
||||||
|
### Restricting available homeserver
|
||||||
|
|
||||||
|
You can restrict the homeserver(s), so that the user can no longer define it himself.
|
||||||
|
|
||||||
|
[Documentation](./docs/restrict-hs.md)
|
||||||
|
|
||||||
|
### Configuring CORS credentials
|
||||||
|
|
||||||
|
You can configure the CORS credentials mode for the Synapse Admin instance.
|
||||||
|
|
||||||
|
[Documentation](./docs/cors-credentials.md)
|
||||||
|
|
||||||
|
### Protecting appservice managed users
|
||||||
|
|
||||||
|
To avoid accidental adjustments of appservice-managed users (e.g., puppets created by a bridge) and breaking the bridge,
|
||||||
|
you can specify the list of MXIDs (regexp) that should be prohibited from any changes, except display name and avatar.
|
||||||
|
|
||||||
|
[Documentation](./docs/system-users.md)
|
||||||
|
|
||||||
|
### Adding custom menu items
|
||||||
|
|
||||||
|
You can add custom menu items to the main menu by providing a `menu` array in the config.
|
||||||
|
|
||||||
|
[Documentation](./docs/custom-menu.md)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Supported Synapse
|
### Supported Synapse
|
||||||
|
|
||||||
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.93.0 for all functions to work as expected!
|
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.116.0 for all functions to work as expected!
|
||||||
|
|
||||||
You get your server version with the request `/_synapse/admin/v1/server_version`.
|
You get your server version with the request `/_synapse/admin/v1/server_version`.
|
||||||
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
|
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
|
||||||
@@ -32,7 +225,7 @@ See also [Synapse administration endpoints](https://element-hq.github.io/synapse
|
|||||||
### Use without install
|
### Use without install
|
||||||
|
|
||||||
You can use the current version of Synapse Admin without own installation direct
|
You can use the current version of Synapse Admin without own installation direct
|
||||||
via [GitHub Pages](https://awesome-technologies.github.io/synapse-admin/).
|
via [admin.etke.cc](https://admin.etke.cc).
|
||||||
|
|
||||||
**Note:**
|
**Note:**
|
||||||
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
|
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
|
||||||
@@ -51,22 +244,24 @@ You have three options:
|
|||||||
|
|
||||||
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
|
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
|
||||||
- configure a vhost for synapse admin on your webserver
|
- configure a vhost for synapse admin on your webserver
|
||||||
- download the .tar.gz from the latest release: https://github.com/Awesome-Technologies/synapse-admin/releases/latest
|
- download the .tar.gz [from the latest release](https://github.com/etkecc/synapse-admin/releases/latest)
|
||||||
- unpack the .tar.gz
|
- unpack the .tar.gz
|
||||||
- move or symlink the `synapse-admin-x.x.x` into your vhosts root dir
|
- move or symlink the `synapse-admin` into your vhosts root dir
|
||||||
- open the url of the vhost in your browser
|
- open the url of the vhost in your browser
|
||||||
|
|
||||||
|
[Reverse Proxy Documentation with Examples](./docs/reverse-proxy.md)
|
||||||
|
|
||||||
#### Steps for 2)
|
#### Steps for 2)
|
||||||
|
|
||||||
- make sure you have installed the following: git, yarn, nodejs
|
- make sure you have installed the following: git, yarn, nodejs
|
||||||
- download the source code: `git clone https://github.com/Awesome-Technologies/synapse-admin.git`
|
- download the source code: `git clone https://github.com/etkecc/synapse-admin.git`
|
||||||
- change into downloaded directory: `cd synapse-admin`
|
- change into downloaded directory: `cd synapse-admin`
|
||||||
- download dependencies: `yarn install`
|
- download dependencies: `yarn install`
|
||||||
- start web server: `yarn start`
|
- start web server: `yarn start`
|
||||||
|
|
||||||
#### Steps for 3)
|
#### Steps for 3)
|
||||||
|
|
||||||
- run the Docker container from the public docker registry: `docker run -p 8080:80 awesometechnologies/synapse-admin` or use the [docker-compose.yml](docker-compose.yml): `docker-compose up -d`
|
- run the Docker container from the public docker registry: `docker run -p 8080:80 ghcr.io/etkecc/synapse-admin` or use the [docker-compose.yml](docker-compose.yml): `docker-compose up -d`
|
||||||
|
|
||||||
> note: if you're building on an architecture other than amd64 (for example a raspberry pi), make sure to define a maximum ram for node. otherwise the build will fail.
|
> note: if you're building on an architecture other than amd64 (for example a raspberry pi), make sure to define a maximum ram for node. otherwise the build will fail.
|
||||||
|
|
||||||
@@ -76,7 +271,8 @@ You have three options:
|
|||||||
container_name: synapse-admin
|
container_name: synapse-admin
|
||||||
hostname: synapse-admin
|
hostname: synapse-admin
|
||||||
build:
|
build:
|
||||||
context: https://github.com/Awesome-Technologies/synapse-admin.git
|
context: https://github.com/etkecc/synapse-admin.git
|
||||||
|
dockerfile: Dockerfile.build
|
||||||
args:
|
args:
|
||||||
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||||
# - NODE_OPTIONS="--max_old_space_size=1024"
|
# - NODE_OPTIONS="--max_old_space_size=1024"
|
||||||
@@ -88,38 +284,7 @@ You have three options:
|
|||||||
|
|
||||||
- browse to http://localhost:8080
|
- browse to http://localhost:8080
|
||||||
|
|
||||||
### Restricting available homeserver
|
### Serving Synapse Admin on a different path
|
||||||
|
|
||||||
You can restrict the homeserver(s), so that the user can no longer define it himself.
|
|
||||||
|
|
||||||
Edit `config.json` to restrict either to a single homeserver:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"restrictBaseUrl": "https://your-matrixs-erver.example.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
or to a list of homeservers:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"restrictBaseUrl": ["https://your-first-matrix-server.example.com", "https://your-second-matrix-server.example.com"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `config.json` can be injected into a Docker container using a bind mount.
|
|
||||||
|
|
||||||
```yml
|
|
||||||
services:
|
|
||||||
synapse-admin:
|
|
||||||
...
|
|
||||||
volumes:
|
|
||||||
./config.json:/app/config.json:ro
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Serving Synapse-Admin on a different path
|
|
||||||
|
|
||||||
The path prefix where synapse-admin is served can only be changed during the build step.
|
The path prefix where synapse-admin is served can only be changed during the build step.
|
||||||
|
|
||||||
@@ -127,7 +292,7 @@ If you downloaded the source code, use `yarn build --base=/my-prefix` to set a p
|
|||||||
|
|
||||||
If you want to build your own Docker container, use the `BASE_PATH` argument.
|
If you want to build your own Docker container, use the `BASE_PATH` argument.
|
||||||
|
|
||||||
We do not support directly changing the path where Synapse-Admin is served in the pre-built Docker container. Instead please use a reverse proxy if you need to move Synapse-Admin to a different base path. If you want to serve multiple applications with different paths on the same domain, you need a reverse proxy anyway.
|
We do not support directly changing the path where Synapse Admin is served in the pre-built Docker container. Instead please use a reverse proxy if you need to move Synapse Admin to a different base path. If you want to serve multiple applications with different paths on the same domain, you need a reverse proxy anyway.
|
||||||
|
|
||||||
Example for Traefik:
|
Example for Traefik:
|
||||||
|
|
||||||
@@ -136,7 +301,7 @@ Example for Traefik:
|
|||||||
```yml
|
```yml
|
||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:mimolette
|
image: traefik:v3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 80:80
|
- 80:80
|
||||||
@@ -145,21 +310,18 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
synapse-admin:
|
synapse-admin:
|
||||||
image: awesometechnologies/synapse-admin:latest
|
image: ghcr.io/etkecc/synapse-admin:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.synapse-admin.rule=Host(`example.com`)&&PathPrefix(`/admin`)"
|
- "traefik.http.routers.admin.rule=Host(`example.com`) && PathPrefix(`/admin`)"
|
||||||
- "traefik.http.routers.synapse-admin.middlewares=admin,admin_path"
|
- "traefik.http.services.admin.loadbalancer.server.port=80"
|
||||||
- "traefik.http.middlewares.admin.redirectregex.regex=^(.*)/admin/?"
|
- "traefik.http.middlewares.admin-slashless-redirect.redirectregex.regex=(/admin)$$"
|
||||||
- "traefik.http.middlewares.admin.redirectregex.replacement=$${1}/admin/"
|
- "traefik.http.middlewares.admin-slashless-redirect.redirectregex.replacement=$${1}/"
|
||||||
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
|
- "traefik.http.middlewares.admin-strip-prefix.stripprefix.prefixes=/admin"
|
||||||
|
- "traefik.http.routers.admin.middlewares=admin-slashless-redirect,admin-strip-prefix"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE
|
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE
|
||||||
|
34
docker-compose-dev.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
synapse:
|
||||||
|
image: ghcr.io/element-hq/synapse:develop
|
||||||
|
entrypoint: python
|
||||||
|
command: "-m synapse.app.homeserver -c /config/homeserver.yaml"
|
||||||
|
ports:
|
||||||
|
- "8008:8008"
|
||||||
|
volumes:
|
||||||
|
- ./testdata/synapse:/config
|
||||||
|
- ./testdata/synapse.data:/media-store
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:alpine
|
||||||
|
volumes:
|
||||||
|
- ./testdata/postgres.data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: synapse
|
||||||
|
POSTGRES_PASSWORD: synapse
|
||||||
|
POSTGRES_DB: synapse
|
||||||
|
POSTGRES_INITDB_ARGS: "--lc-collate C --lc-ctype C --encoding UTF8"
|
||||||
|
|
||||||
|
element:
|
||||||
|
image: docker.io/vectorim/element-web:latest
|
||||||
|
depends_on:
|
||||||
|
synapse:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./testdata/element/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- /dev/null:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./testdata/element/config.json:/app/config.json:ro
|
||||||
|
|
@@ -2,13 +2,14 @@ services:
|
|||||||
synapse-admin:
|
synapse-admin:
|
||||||
container_name: synapse-admin
|
container_name: synapse-admin
|
||||||
hostname: synapse-admin
|
hostname: synapse-admin
|
||||||
image: awesometechnologies/synapse-admin:latest
|
image: ghcr.io/etkecc/synapse-admin:latest
|
||||||
# build:
|
# build:
|
||||||
# context: .
|
# context: .
|
||||||
|
# dockerfile: Dockerfile.build
|
||||||
|
|
||||||
# to use the docker-compose as standalone without a local repo clone,
|
# to use the docker-compose as standalone without a local repo clone,
|
||||||
# replace the context definition with this:
|
# replace the context definition with this:
|
||||||
# context: https://github.com/Awesome-Technologies/synapse-admin.git
|
# context: https://github.com/etkecc/synapse-admin.git
|
||||||
|
|
||||||
# args:
|
# args:
|
||||||
# - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
# - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||||
|
44
docs/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Documentation
|
||||||
|
|
||||||
|
Synapse Admin documentation is under construction right now, so PRs are greatly appreciated!
|
||||||
|
|
||||||
|
Table of contents:
|
||||||
|
<!-- vim-markdown-toc GFM -->
|
||||||
|
|
||||||
|
* [Configuration](#configuration)
|
||||||
|
* [Features](#features)
|
||||||
|
* [Deployment](#deployment)
|
||||||
|
|
||||||
|
<!-- vim-markdown-toc -->
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
[Full configuration documentation](./config.md)
|
||||||
|
|
||||||
|
Specific configuration options:
|
||||||
|
|
||||||
|
* [Customizing CORS credentials](./cors-credentials.md)
|
||||||
|
* [Restricting available homeserver](./restrict-hs.md)
|
||||||
|
* [System / Appservice-managed Users](./system-users.md)
|
||||||
|
* [Custom Menu Items](./custom-menu.md)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* [User Badges](./user-badges.md)
|
||||||
|
* [Prefilling the Login Form](./prefill-login-form.md)
|
||||||
|
|
||||||
|
for [etke.cc](https://etke.cc) customers only:
|
||||||
|
|
||||||
|
> **Note:** The following features are only available for etke.cc customers. Due to specifics of the implementation,
|
||||||
|
they are not available for any other Synapse Admin deployments.
|
||||||
|
|
||||||
|
* [Server Status icon](../src/components/etke.cc/README.md#server-status-icon)
|
||||||
|
* [Server Status page](../src/components/etke.cc/README.md#server-status-page)
|
||||||
|
* [Server Actions page](../src/components/etke.cc/README.md#server-actions-page)
|
||||||
|
* [Server Commands Panel](../src/components/etke.cc/README.md#server-commands-panel)
|
||||||
|
* [Server Notifications icon](../src/components/etke.cc/README.md#server-notifications-icon)
|
||||||
|
* [Server Notifications page](../src/components/etke.cc/README.md#server-notifications-page)
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
* [Serving Synapse Admin behind a reverse proxy](./reverse-proxy.md)
|
100
docs/config.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
Synapse Admin could be configured using the following ways (both are optional, and both could be used together):
|
||||||
|
|
||||||
|
* By providing the `config.json` file alongside with the Synapse Admin deployment, example: [admin.etke.cc/config.json](https://admin.etke.cc/config.json)
|
||||||
|
* By providing configuration under the `cc.etke.synapse-admin` key in the `/.well-known/matrix/client` file, example:
|
||||||
|
[demo.etke.host/.well-known/matrix/client](https://demo.etke.host/.well-known/matrix/client)
|
||||||
|
|
||||||
|
In case you are an [etke.cc](https://etke.cc) customer,
|
||||||
|
or use [spantaleev/matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy),
|
||||||
|
or [etkecc/ansible](https://github.com/etkecc/ansible),
|
||||||
|
configuration will be automatically added to the `/.well-known/matrix/client` file.
|
||||||
|
|
||||||
|
**Why `/.well-known/matrix/client`?**
|
||||||
|
|
||||||
|
Because any instance of Synapse Admin will automatically pick up the configuration from the homeserver.
|
||||||
|
Common use case when you have a Synapse server running, but don't want (or can't) deploy Synapse Admin alongside with it.
|
||||||
|
In this case, you could provide the configuration in the `/.well-known/matrix/client` file,
|
||||||
|
and any Synapse Admin instance (e.g., [admin.etke.cc](https://admin.etke.cc) will pick it up.
|
||||||
|
|
||||||
|
Another common case is when you have multiple Synapse servers running and want to use a single Synapse Admin instance to manage them all.
|
||||||
|
In this case, you could provide the configuration in the `/.well-known/matrix/client` file for each of the servers.
|
||||||
|
|
||||||
|
## Configuration options
|
||||||
|
|
||||||
|
* `restrictBaseUrl` - restrictBaseUrl restricts the Synapse Admin instance to work only with specific homeserver(-s).
|
||||||
|
It accepts both a string and an array of strings.
|
||||||
|
The homeserver URL should be the _actual_ homeserver URL, and not the delegated one.
|
||||||
|
Example: `https://matrix.example.com` or `https://synapse.example.net`
|
||||||
|
[More details](restrict-hs.md)
|
||||||
|
* `corsCredentials` - configure the CORS credentials for the Synapse Admin instance.
|
||||||
|
It accepts the following values:
|
||||||
|
* `same-origin` (default): Cookies will be sent only if the request is made from the same origin as the server.
|
||||||
|
* `include`: Cookies will be sent regardless of the origin of the request.
|
||||||
|
* `omit`: Cookies will not be sent with the request.
|
||||||
|
[More details](cors-credentials.md)
|
||||||
|
* `asManagedUsers` - protect system user accounts managed by appservices (such as bridges) / system (such as bots) from accidental changes.
|
||||||
|
By defining a list of MXID regex patterns, you can protect these accounts from accidental changes.
|
||||||
|
Example: `^@baibot:example\\.com$`, `^@slackbot:example\\.com$`, `^@slack_[a-zA-Z0-9\\-]+:example\\.com$`, `^@telegrambot:example\\.com$`, `^@telegram_[a-zA-Z0-9]+:example\\.com$`
|
||||||
|
[More details](system-users.md)
|
||||||
|
* `menu` - add custom menu items to the main menu (sidebar) by providing a `menu` array in the config.
|
||||||
|
Each `menu` item can contain the following fields:
|
||||||
|
* `label` (required): The text to display in the menu.
|
||||||
|
* `icon` (optional): The icon to display next to the label, one of the [src/utils/icons.ts](../src/utils/icons.ts) icons, otherwise a default icon will be used.
|
||||||
|
* `url` (required): The URL to navigate to when the menu item is clicked.
|
||||||
|
[More details](custom-menu.md)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### config.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"restrictBaseUrl": [
|
||||||
|
"https://matrix.example.com",
|
||||||
|
"https://synapse.example.net"
|
||||||
|
],
|
||||||
|
"asManagedUsers": [
|
||||||
|
"^@baibot:example\\.com$",
|
||||||
|
"^@slackbot:example\\.com$",
|
||||||
|
"^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
|
||||||
|
"^@telegrambot:example\\.com$",
|
||||||
|
"^@telegram_[a-zA-Z0-9]+:example\\.com$"
|
||||||
|
],
|
||||||
|
"menu": [
|
||||||
|
{
|
||||||
|
"label": "Contact support",
|
||||||
|
"icon": "SupportAgent",
|
||||||
|
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/.well-known/matrix/client`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cc.etke.synapse-admin": {
|
||||||
|
"restrictBaseUrl": [
|
||||||
|
"https://matrix.example.com",
|
||||||
|
"https://synapse.example.net"
|
||||||
|
],
|
||||||
|
"asManagedUsers": [
|
||||||
|
"^@baibot:example\\.com$",
|
||||||
|
"^@slackbot:example\\.com$",
|
||||||
|
"^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
|
||||||
|
"^@telegrambot:example\\.com$",
|
||||||
|
"^@telegram_[a-zA-Z0-9]+:example\\.com$"
|
||||||
|
],
|
||||||
|
"menu": [
|
||||||
|
{
|
||||||
|
"label": "Contact support",
|
||||||
|
"icon": "SupportAgent",
|
||||||
|
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
35
docs/cors-credentials.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# CORS Credentials
|
||||||
|
|
||||||
|
If you'd like to use cookie-based authentication
|
||||||
|
(for example, [ForwardAuth with Authelia](https://github.com/Awesome-Technologies/synapse-admin/issues/655)),
|
||||||
|
you can configure the `corsCredentials` option in the `config.json` file or in the `/.well-known/matrix/client` file.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
> [Documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#including_credentials)
|
||||||
|
|
||||||
|
The `corsCredentials` option accepts the following values:
|
||||||
|
|
||||||
|
* `same-origin` (default): Cookies will be sent only if the request is made from the same origin as the server.
|
||||||
|
* `include`: Cookies will be sent regardless of the origin of the request.
|
||||||
|
* `omit`: Cookies will not be sent with the request.
|
||||||
|
|
||||||
|
[Configuration options](config.md)
|
||||||
|
|
||||||
|
### config.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"corsCredentials": "include"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/.well-known/matrix/client`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cc.etke.synapse-admin": {
|
||||||
|
"corsCredentials": "include"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
47
docs/custom-menu.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Custom Menu Items
|
||||||
|
|
||||||
|
You can add custom menu items to the main menu (sidebar) by providing a `menu` array in the config.
|
||||||
|
This is useful for adding links to external sites or other pages in your documentation, like a support page or internal wiki.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The examples below contain the configuration settings to add a link to the [Synapse Admin issues](https://github.com/etke.cc/synapse-admin/issues).
|
||||||
|
|
||||||
|
Each `menu` item can contain the following fields:
|
||||||
|
|
||||||
|
* `label` (required): The text to display in the menu.
|
||||||
|
* `icon` (optional): The icon to display next to the label, one of the [src/utils/icons.ts](../src/utils/icons.ts) icons, otherwise a
|
||||||
|
default icon will be used.
|
||||||
|
* `url` (required): The URL to navigate to when the menu item is clicked.
|
||||||
|
|
||||||
|
[Configuration options](config.md)
|
||||||
|
|
||||||
|
### config.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"menu": [
|
||||||
|
{
|
||||||
|
"label": "Contact support",
|
||||||
|
"icon": "SupportAgent",
|
||||||
|
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/.well-known/matrix/client`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cc.etke.synapse-admin": {
|
||||||
|
"menu": [
|
||||||
|
{
|
||||||
|
"label": "Contact support",
|
||||||
|
"icon": "SupportAgent",
|
||||||
|
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
48
docs/prefill-login-form.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Prefilling the Login Form
|
||||||
|
|
||||||
|
In some cases you may wish to prefill/preset the login form fields when sharing a link to a Synapse Admin instance.
|
||||||
|
This can be done by adding the following query parameters to the URL:
|
||||||
|
|
||||||
|
* `username` - The username to prefill in the username field.
|
||||||
|
* `server` - The server to prefill in the homeserver url field.
|
||||||
|
|
||||||
|
The following query params will work only if the Synapse Admin is loaded from `localhost` or `127.0.0.1`:
|
||||||
|
|
||||||
|
* `password` - The password to prefill in the password field (credentials auth). **NEVER** use this in production.
|
||||||
|
* `accessToken` - The access token to prefill in the access token field (access token auth). **NEVER** use this in production.
|
||||||
|
|
||||||
|
> **WARNING**: Never use the `password` or `accessToken` query parameters in production as they can be easily extracted
|
||||||
|
from the URL. These are only meant for development purposes and local environments.
|
||||||
|
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
https://admin.etke.cc?username=admin&server=https://matrix.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
This will open `Credentials` (username/password) login form with the username field prefilled with `admin` and the
|
||||||
|
Homeserver URL field prefilled with `https://matrix.example.com`.
|
||||||
|
|
||||||
|
### Development and Local environments
|
||||||
|
|
||||||
|
**With Password**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
http://localhost:8080?username=admin&server=https://matrix.example.com&password=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
This will open `Credentials` (username/password) login form with the username field prefilled with `admin`, the
|
||||||
|
Homeserver URL field prefilled with `https://matrix.example.com` and the password field prefilled with `secret`.
|
||||||
|
|
||||||
|
|
||||||
|
**With Access Token**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
http://localhost:8080?server=https://matrix.example.com&accessToken=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
This will open `Access Token` login form with the Homeserver URL field prefilled with `https://matrix.example.com` and
|
||||||
|
the access token field prefilled with `secret`.
|
44
docs/restrict-hs.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Restricting available homeserver
|
||||||
|
|
||||||
|
If you want to have your Synapse Admin instance work only with specific homeserver(-s),
|
||||||
|
you can do that by setting `restrictBaseUrl` in the configuration.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
You can do that for a single homeserver or multiple homeservers at once, as `restrictBaseUrl` accepts both a string and
|
||||||
|
an array of strings.
|
||||||
|
|
||||||
|
The examples below contain the configuration settings to restrict the Synapse Admin instance to work only with
|
||||||
|
`example.com` (with Synapse runing at `matrix.example.com`) and
|
||||||
|
`example.net` (with Synapse running at `synapse.example.net`) homeservers.
|
||||||
|
Note that the homeserver URL should be the _actual_ homeserver URL, and not the delegated one.
|
||||||
|
|
||||||
|
So, if you have a homeserver `example.com` where users have MXIDs like `@user:example.com`,
|
||||||
|
but actual Synapse is installed on `matrix.example.com` subdomain, you should use `https://matrix.example.com` in the
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
[Configuration options](config.md)
|
||||||
|
|
||||||
|
### config.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"restrictBaseUrl": [
|
||||||
|
"https://matrix.example.com",
|
||||||
|
"https://synapse.example.net"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/.well-known/matrix/client`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cc.etke.synapse-admin": {
|
||||||
|
"restrictBaseUrl": [
|
||||||
|
"https://matrix.example.com",
|
||||||
|
"https://synapse.example.net"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
53
docs/reverse-proxy.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Serving Synapse Admin behind a reverse proxy
|
||||||
|
|
||||||
|
Your are supposed to do so for any service you want to expose to the internet,
|
||||||
|
and here you can find specific instructions and example configurations for Synapse Admin.
|
||||||
|
|
||||||
|
## Nginx
|
||||||
|
|
||||||
|
Place the config below into `/etc/nginx/conf.d/synapse-admin.conf` (don't forget to replace `server_name` and `root`):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name example.com; # REPLACE with your domain
|
||||||
|
root /var/www/synapse-admin; # REPLACE with path where you extracted synapse admin
|
||||||
|
index index.html;
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|woff|woff2|ttf|eot|webp)$ {
|
||||||
|
expires 30d; # Set caching for static assets
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain application/javascript application/json text/css text/xml application/xml+rss;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After you've done that, ensure that the configuration is correct by running `nginx -t` and then reload Nginx
|
||||||
|
(e.g. `systemctl reload nginx`).
|
||||||
|
|
||||||
|
> **Note:** This configuration doesn't cover HTTPS, which is highly recommended to use. You can find more information
|
||||||
|
about setting up HTTPS in the [Nginx documentation](https://nginx.org/en/docs/http/configuring_https_servers.html).
|
||||||
|
|
||||||
|
## Traefik (docker labels)
|
||||||
|
|
||||||
|
If you are using Traefik as a reverse proxy, you can use the following labels, `docker-compose.yml` example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
synapse-admin:
|
||||||
|
image: ghcr.io/etkecc/synapse-admin:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.synapse-admin.rule=Host(`example.com`)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other reverse proxies
|
||||||
|
|
||||||
|
There is no examples for other reverse proxies yet, and so PRs are greatly appreciated.
|
44
docs/system-users.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# System / Appservice-managed Users
|
||||||
|
|
||||||
|
Inadvertently altering system user accounts managed by appservices (such as bridges) / system (such as bots) is a common issue.
|
||||||
|
Editing, deleting, locking, or changing the passwords of these appservice-managed accounts can cause serious problems.
|
||||||
|
To prevent this, we've added a new feature that blocks these types of modifications to such accounts,
|
||||||
|
while still allowing other risk-free changes (changing display names and avatars).
|
||||||
|
By defining a list of MXID regex patterns in the new `asManagedUsers` configuration setting,
|
||||||
|
you can protect these accounts from accidental changes.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The examples below contain the configuration settings to mark
|
||||||
|
[Telegram bridge (mautrix-telegram)](https://github.com/mautrix/telegram),
|
||||||
|
[Slack bridge (mautrix-slack)](https://github.com/mautrix/slack),
|
||||||
|
and [Baibot](https://github.com/etkecc/baibot) users of `example.com` homeserver as appservice-managed users,
|
||||||
|
just to illustrate the options to protect both specific MXIDs (as in the Baibot example) and all puppets of a bridge (as in the Telegram and Slack examples).
|
||||||
|
|
||||||
|
[Configuration options](config.md)
|
||||||
|
|
||||||
|
### config.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
"asManagedUsers": [
|
||||||
|
"^@baibot:example\\.com$",
|
||||||
|
"^@slackbot:example\\.com$",
|
||||||
|
"^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
|
||||||
|
"^@telegrambot:example\\.com$",
|
||||||
|
"^@telegram_[a-zA-Z0-9]+:example\\.com$"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/.well-known/matrix/client`
|
||||||
|
|
||||||
|
```json
|
||||||
|
"cc.etke.synapse-admin": {
|
||||||
|
"asManagedUsers": [
|
||||||
|
"^@baibot:example\\.com$",
|
||||||
|
"^@slackbot:example\\.com$",
|
||||||
|
"^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
|
||||||
|
"^@telegrambot:example\\.com$",
|
||||||
|
"^@telegram_[a-zA-Z0-9]+:example\\.com$"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
36
docs/user-badges.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# User Badges
|
||||||
|
|
||||||
|
To help with identifying users with certain roles or permissions, we have implemented a badge system.
|
||||||
|
These badges are displayed on the user's avatar and have a handy tooltip that explains what the badge means.
|
||||||
|
|
||||||
|
## Available Badges
|
||||||
|
|
||||||
|
### 🧙 You
|
||||||
|
|
||||||
|
This badge is displayed on your user's avatar.
|
||||||
|
Tooltip for this badge will contain additional information, e.g.: `You (Admin)`.
|
||||||
|
|
||||||
|
### 👑 Admin
|
||||||
|
|
||||||
|
This badge is displayed on homeserver admins' avatars.
|
||||||
|
Tooltip for this badge is `Admin`.
|
||||||
|
|
||||||
|
### 🛡️ Appservice/System-managed
|
||||||
|
|
||||||
|
This badge is displayed on users that are managed by an appservices (or system), [more details](./system-users.md).
|
||||||
|
Tooltip for this badge will contain additional information, e.g.: `System-managed (Bot)`.
|
||||||
|
|
||||||
|
### 🤖 Bot
|
||||||
|
|
||||||
|
This badge is displayed on bots' avatars (users with the `user_type` set to `bot`).
|
||||||
|
Tooltip for this badge is `Bot`.
|
||||||
|
|
||||||
|
### 📞 Support
|
||||||
|
|
||||||
|
This badge is displayed on users that are part of the support team (users with the `user_type` set to `support`).
|
||||||
|
Tooltip for this badge is `Support`.
|
||||||
|
|
||||||
|
### 👤 Regular User
|
||||||
|
|
||||||
|
This badge is displayed on regular users' avatars.
|
||||||
|
Tooltip for this badge is `Regular User`.
|
22
index.html
@@ -4,17 +4,14 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta name="description" content="Synapse Admin" />
|
||||||
name="description"
|
|
||||||
content="Synapse-Admin"
|
|
||||||
/>
|
|
||||||
<!--
|
<!--
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
-->
|
-->
|
||||||
<link rel="manifest" href="./manifest.json" />
|
<link rel="manifest" href="./manifest.json" />
|
||||||
<link rel="shortcut icon" href="./favicon.ico" />
|
<link rel="shortcut icon" href="./favicon.ico" />
|
||||||
<title>Synapse-Admin</title>
|
<title>Synapse Admin</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -22,6 +19,11 @@
|
|||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
min-height: 90vh !important;
|
||||||
|
height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
.loader-container {
|
.loader-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -120,13 +122,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
<footer
|
<span id="js-version" style="display: none;"></span>
|
||||||
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
|
|
||||||
<a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"
|
|
||||||
style="margin-left: 1em; color: #888; font-family: Roboto, Helvetica, Arial, sans-serif; font-weight: 100; font-size: 0.8em; text-decoration: none;">
|
|
||||||
Synapse-Admin <b><span id="version"></span></b> by Awesome Technologies Innovationslabor GmbH
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</body>
|
</body>
|
||||||
<script>document.getElementById("version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
|
<script>document.getElementById("js-version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -2,12 +2,30 @@ import type { JestConfigWithTsJest } from "ts-jest";
|
|||||||
|
|
||||||
const config: JestConfigWithTsJest = {
|
const config: JestConfigWithTsJest = {
|
||||||
preset: "ts-jest",
|
preset: "ts-jest",
|
||||||
testEnvironment: "jsdom",
|
testEnvironment: "jest-fixed-jsdom",
|
||||||
collectCoverage: true,
|
collectCoverage: true,
|
||||||
coveragePathIgnorePatterns: ["node_modules", "dist"],
|
coveragePathIgnorePatterns: ["node_modules", "dist"],
|
||||||
coverageDirectory: "<rootDir>/coverage/",
|
coverageDirectory: "<rootDir>/coverage/",
|
||||||
coverageReporters: ["html", "text", "text-summary", "cobertura"],
|
coverageReporters: ["html", "text", "text-summary", "cobertura"],
|
||||||
extensionsToTreatAsEsm: [".ts", ".tsx"],
|
extensionsToTreatAsEsm: [".ts", ".tsx"],
|
||||||
setupFilesAfterEnv: ["<rootDir>/src/jest.setup.ts"],
|
setupFilesAfterEnv: ["<rootDir>/src/jest.setup.ts"],
|
||||||
|
transform: {
|
||||||
|
"^.+\\.tsx?$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
diagnostics: {
|
||||||
|
ignoreCodes: [1343],
|
||||||
|
},
|
||||||
|
astTransformers: {
|
||||||
|
before: [
|
||||||
|
{
|
||||||
|
path: "ts-jest-mock-import-meta",
|
||||||
|
options: { metaObjectReplacement: { env: { BASE_URL: "/" } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
46
justfile
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Shows help
|
||||||
|
default:
|
||||||
|
@just --list --justfile {{ justfile() }}
|
||||||
|
|
||||||
|
# build the app
|
||||||
|
build: __install
|
||||||
|
@yarn run build --base=./
|
||||||
|
|
||||||
|
# run the app in a development mode
|
||||||
|
run:
|
||||||
|
@yarn start --host 0.0.0.0
|
||||||
|
|
||||||
|
# run dev stack and start the app in a development mode
|
||||||
|
run-dev:
|
||||||
|
@echo "Starting the database..."
|
||||||
|
@docker-compose -f docker-compose-dev.yml up -d postgres
|
||||||
|
@echo "Starting Synapse..."
|
||||||
|
@docker-compose -f docker-compose-dev.yml up -d synapse
|
||||||
|
@echo "Starting Element Web..."
|
||||||
|
@docker-compose -f docker-compose-dev.yml up -d element
|
||||||
|
@echo "Ensure admin user is registered..."
|
||||||
|
@docker-compose -f docker-compose-dev.yml exec synapse register_new_matrix_user --admin -u admin -p admin -c /config/homeserver.yaml http://localhost:8008 || true
|
||||||
|
@echo "Starting the app..."
|
||||||
|
@yarn start --host 0.0.0.0
|
||||||
|
|
||||||
|
# stop the dev stack
|
||||||
|
stop-dev:
|
||||||
|
@docker-compose -f docker-compose-dev.yml stop
|
||||||
|
|
||||||
|
# register a user in the dev stack
|
||||||
|
register-user localpart password *admin:
|
||||||
|
docker-compose exec synapse register_new_matrix_user {{ if admin == "1" {"--admin"} else {"--no-admin"} }} -u {{ localpart }} -p {{ password }} -c /config/homeserver.yaml http://localhost:8008
|
||||||
|
|
||||||
|
# run yarn {fix,lint,test} commands
|
||||||
|
test:
|
||||||
|
@-yarn run fix
|
||||||
|
@-yarn run lint
|
||||||
|
@-yarn run test
|
||||||
|
|
||||||
|
# run the app in a production mode
|
||||||
|
run-prod: build
|
||||||
|
@python -m http.server -d dist 1313
|
||||||
|
|
||||||
|
# install the project
|
||||||
|
__install:
|
||||||
|
@yarn install --immutable --network-timeout=300000
|
110
package.json
@@ -3,79 +3,78 @@
|
|||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"description": "Admin GUI for the Matrix.org server Synapse",
|
"description": "Admin GUI for the Matrix.org server Synapse",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Awesome Technologies Innovationslabor GmbH",
|
"author": "etke.cc (originally by Awesome Technologies Innovationslabor GmbH)",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/Awesome-Technologies/synapse-admin"
|
"url": "https://github.com/etkecc/synapse-admin"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.4.1",
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.7.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@mui/utils": "^6.1.3",
|
"@testing-library/dom": "^10.0.0",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/jest-dom": "^6.0.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/lodash": "^4.17.7",
|
"@types/node": "^22.15.19",
|
||||||
"@types/node": "^20.14.12",
|
"@types/papaparse": "^5.3.16",
|
||||||
"@types/papaparse": "^5.3.14",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react": "^18.3.3",
|
"@typescript-eslint/eslint-plugin": "^8.32.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
"@typescript-eslint/parser": "^8.32.0",
|
||||||
"@typescript-eslint/parser": "^7.16.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"eslint": "^9.27.0",
|
||||||
"eslint": "^8.57.0",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
"eslint-plugin-prettier": "^5.4.0",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"eslint-plugin-yaml": "^1.0.3",
|
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-fetch-mock": "^3.0.3",
|
"jest-fetch-mock": "^3.0.3",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.5.3",
|
||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^19.1.0",
|
||||||
"ts-jest": "^29.2.3",
|
"ts-jest": "^29.3.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.32.0",
|
"typescript-eslint": "^8.32.1",
|
||||||
"vite": "^5.4.19",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-version-mark": "^0.1.0"
|
"vite-plugin-version-mark": "^0.1.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@haleos/ra-language-german": "^1.0.0",
|
"@haleos/ra-language-german": "^1.0.0",
|
||||||
"@haxqer/ra-language-chinese": "^4.16.2",
|
"@haxqer/ra-language-chinese": "^4.16.2",
|
||||||
"@mui/icons-material": "^5.16.4",
|
"@mui/icons-material": "^6.4.8",
|
||||||
"@mui/material": "^5.16.4",
|
"@mui/material": "^6.4.8",
|
||||||
"@tanstack/react-query": "^5.59.12",
|
"@mui/utils": "^7.1.0",
|
||||||
|
"@tanstack/react-query": "^5.76.1",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
"jest-fixed-jsdom": "^0.0.9",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.5.1",
|
||||||
"query-string": "^7.1.3",
|
"ra-core": "^5.4.4",
|
||||||
"ra-core": "^5.2.3",
|
"ra-i18n-polyglot": "^5.4.4",
|
||||||
"ra-i18n-polyglot": "^5.2.3",
|
"ra-language-english": "^5.4.4",
|
||||||
"ra-language-english": "^5.2.3",
|
"ra-language-farsi": "^5.1.0",
|
||||||
"ra-language-farsi": "^5.0.0",
|
"ra-language-french": "^5.8.2",
|
||||||
"ra-language-french": "^5.2.3",
|
|
||||||
"ra-language-italian": "^3.13.1",
|
"ra-language-italian": "^3.13.1",
|
||||||
"ra-language-russian": "^4.14.2",
|
"ra-language-russian": "^5.4.3",
|
||||||
"react": "^18.3.1",
|
"react": "^19.1.0",
|
||||||
"react-admin": "^5.2.3",
|
"react-admin": "^5.8.2",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.52.1",
|
"react-hook-form": "^7.56.4",
|
||||||
"react-is": "^18.3.1",
|
"react-is": "^19.1.0",
|
||||||
"react-router": "^6.28.1",
|
"ts-jest-mock-import-meta": "^1.3.0",
|
||||||
"react-router-dom": "^6.28.1"
|
"react-router": "^7.6.0",
|
||||||
|
"react-router-dom": "^7.6.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite serve",
|
"start": "vite serve",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint --ignore-path .gitignore --ext .ts,.tsx,.yml,.yaml .",
|
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ignore-path .gitignore --ignore-pattern testdata/ --ext .ts,.tsx .",
|
||||||
"fix": "yarn lint --fix",
|
"fix": "yarn lint --fix",
|
||||||
"test": "yarn jest",
|
"test": "yarn jest",
|
||||||
"test:watch": "yarn jest --watch"
|
"test:watch": "yarn jest --watch"
|
||||||
@@ -88,15 +87,13 @@
|
|||||||
"import",
|
"import",
|
||||||
"prettier",
|
"prettier",
|
||||||
"unused-imports",
|
"unused-imports",
|
||||||
"@typescript-eslint",
|
"@typescript-eslint"
|
||||||
"yaml"
|
|
||||||
],
|
],
|
||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:@typescript-eslint/stylistic",
|
"plugin:@typescript-eslint/stylistic",
|
||||||
"plugin:import/typescript",
|
"plugin:import/typescript"
|
||||||
"plugin:yaml/legacy"
|
|
||||||
],
|
],
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
@@ -135,8 +132,7 @@
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"unused-imports/no-unused-imports-ts": 2
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.2 KiB |
BIN
public/images/logo.webp
Normal file
After Width: | Height: | Size: 16 KiB |
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"short_name": "Synapse-Admin",
|
|
||||||
"name": "Synapse-Admin",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "favicon.ico",
|
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
|
||||||
"type": "image/x-icon"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"start_url": ".",
|
|
||||||
"display": "standalone",
|
|
||||||
"theme_color": "#000000",
|
|
||||||
"background_color": "#ffffff"
|
|
||||||
}
|
|
BIN
screenshots/auth.webp
Normal file
After Width: | Height: | Size: 83 KiB |
BIN
screenshots/etke.cc/server-actions/page.webp
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
screenshots/etke.cc/server-commands/panel.webp
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
screenshots/etke.cc/server-notifications/badge.webp
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
screenshots/etke.cc/server-notifications/page.webp
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
screenshots/etke.cc/server-status/indicator-sidebar.webp
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
screenshots/etke.cc/server-status/indicator.webp
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
screenshots/etke.cc/server-status/page.webp
Normal file
After Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 222 KiB |
@@ -1,12 +1,32 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import fetchMock from "jest-fetch-mock";
|
import fetchMock from "jest-fetch-mock";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
fetchMock.enableMocks();
|
fetchMock.enableMocks();
|
||||||
|
|
||||||
|
jest.mock("./synapse/authProvider", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
logout: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks before each test
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
// Mock any fetch call to return empty JSON immediately
|
||||||
|
fetchMock.mockResponseOnce(JSON.stringify({}));
|
||||||
|
});
|
||||||
|
|
||||||
it("renders", async () => {
|
it("renders", async () => {
|
||||||
render(<App />);
|
render(
|
||||||
await screen.findAllByText("Welcome to Synapse-admin");
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findAllByText("Welcome to Synapse Admin");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
84
src/App.tsx
@@ -1,10 +1,17 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { merge } from "lodash";
|
import { merge } from "lodash";
|
||||||
import polyglotI18nProvider from "ra-i18n-polyglot";
|
import polyglotI18nProvider from "ra-i18n-polyglot";
|
||||||
|
|
||||||
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
|
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
|
||||||
import { Route } from "react-router-dom";
|
import { Route } from "react-router-dom";
|
||||||
|
|
||||||
import { ImportFeature } from "./components/ImportFeature";
|
import AdminLayout from "./components/AdminLayout";
|
||||||
|
import ServerActionsPage from "./components/etke.cc/ServerActionsPage";
|
||||||
|
import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage";
|
||||||
|
import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
|
||||||
|
import RecurringCommandEdit from "./components/etke.cc/schedules/components/recurring/RecurringCommandEdit";
|
||||||
|
import ScheduledCommandEdit from "./components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit";
|
||||||
|
import ScheduledCommandShow from "./components/etke.cc/schedules/components/scheduled/ScheduledCommandShow";
|
||||||
|
import UserImport from "./components/user-import/UserImport";
|
||||||
import germanMessages from "./i18n/de";
|
import germanMessages from "./i18n/de";
|
||||||
import englishMessages from "./i18n/en";
|
import englishMessages from "./i18n/en";
|
||||||
import frenchMessages from "./i18n/fr";
|
import frenchMessages from "./i18n/fr";
|
||||||
@@ -45,36 +52,49 @@ const i18nProvider = polyglotI18nProvider(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const App = () => (
|
const queryClient = new QueryClient();
|
||||||
<Admin
|
|
||||||
disableTelemetry
|
export const App = () => (
|
||||||
requireAuth
|
<QueryClientProvider client={queryClient}>
|
||||||
loginPage={LoginPage}
|
<Admin
|
||||||
authProvider={authProvider}
|
disableTelemetry
|
||||||
dataProvider={dataProvider}
|
requireAuth
|
||||||
i18nProvider={i18nProvider}
|
layout={AdminLayout}
|
||||||
>
|
loginPage={LoginPage}
|
||||||
<CustomRoutes>
|
authProvider={authProvider}
|
||||||
<Route path="/import_users" element={<ImportFeature />} />
|
dataProvider={dataProvider}
|
||||||
</CustomRoutes>
|
i18nProvider={i18nProvider}
|
||||||
<Resource {...users} />
|
>
|
||||||
<Resource {...rooms} />
|
<CustomRoutes>
|
||||||
<Resource {...userMediaStats} />
|
<Route path="/import_users" element={<UserImport />} />
|
||||||
<Resource {...reports} />
|
<Route path="/server_status" element={<ServerStatusPage />} />
|
||||||
<Resource {...roomDirectory} />
|
<Route path="/server_actions" element={<ServerActionsPage />} />
|
||||||
<Resource {...destinations} />
|
<Route path="/server_actions/scheduled/:id/show" element={<ScheduledCommandShow />} />
|
||||||
<Resource {...registrationToken} />
|
<Route path="/server_actions/scheduled/:id" element={<ScheduledCommandEdit />} />
|
||||||
<Resource name="connections" />
|
<Route path="/server_actions/scheduled/create" element={<ScheduledCommandEdit />} />
|
||||||
<Resource name="devices" />
|
<Route path="/server_actions/recurring/:id" element={<RecurringCommandEdit />} />
|
||||||
<Resource name="room_members" />
|
<Route path="/server_actions/recurring/create" element={<RecurringCommandEdit />} />
|
||||||
<Resource name="users_media" />
|
<Route path="/server_notifications" element={<ServerNotificationsPage />} />
|
||||||
<Resource name="joined_rooms" />
|
</CustomRoutes>
|
||||||
<Resource name="pushers" />
|
<Resource {...users} />
|
||||||
<Resource name="servernotices" />
|
<Resource {...rooms} />
|
||||||
<Resource name="forward_extremities" />
|
<Resource {...userMediaStats} />
|
||||||
<Resource name="room_state" />
|
<Resource {...reports} />
|
||||||
<Resource name="destination_rooms" />
|
<Resource {...roomDirectory} />
|
||||||
</Admin>
|
<Resource {...destinations} />
|
||||||
|
<Resource {...registrationToken} />
|
||||||
|
<Resource name="connections" />
|
||||||
|
<Resource name="devices" />
|
||||||
|
<Resource name="room_members" />
|
||||||
|
<Resource name="users_media" />
|
||||||
|
<Resource name="joined_rooms" />
|
||||||
|
<Resource name="pushers" />
|
||||||
|
<Resource name="servernotices" />
|
||||||
|
<Resource name="forward_extremities" />
|
||||||
|
<Resource name="room_state" />
|
||||||
|
<Resource name="destination_rooms" />
|
||||||
|
</Admin>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
@@ -1,9 +0,0 @@
|
|||||||
import { createContext, useContext } from "react";
|
|
||||||
|
|
||||||
interface AppContextType {
|
|
||||||
restrictBaseUrl: string | string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppContext = createContext({});
|
|
||||||
|
|
||||||
export const useAppContext = () => useContext(AppContext) as AppContextType;
|
|
7
src/Context.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
import { Config } from "./utils/config";
|
||||||
|
|
||||||
|
export const AppContext = createContext<Config>({} as Config);
|
||||||
|
|
||||||
|
export const useAppContext = () => useContext(AppContext) as Config;
|
169
src/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import ManageHistoryIcon from "@mui/icons-material/ManageHistory";
|
||||||
|
import { useEffect, useState, Suspense } from "react";
|
||||||
|
import {
|
||||||
|
CheckForApplicationUpdate,
|
||||||
|
AppBar,
|
||||||
|
TitlePortal,
|
||||||
|
InspectorButton,
|
||||||
|
Confirm,
|
||||||
|
Layout,
|
||||||
|
Logout,
|
||||||
|
Menu,
|
||||||
|
useLogout,
|
||||||
|
UserMenu,
|
||||||
|
useStore,
|
||||||
|
} from "react-admin";
|
||||||
|
|
||||||
|
import Footer from "./Footer";
|
||||||
|
import { LoginMethod } from "../pages/LoginPage";
|
||||||
|
import { MenuItem, GetConfig, ClearConfig } from "../utils/config";
|
||||||
|
import { Icons, DefaultIcon } from "../utils/icons";
|
||||||
|
import { ServerNotificationsBadge } from "./etke.cc/ServerNotificationsBadge";
|
||||||
|
import { ServerProcessResponse, ServerStatusResponse } from "../synapse/dataProvider";
|
||||||
|
import ServerStatusBadge from "./etke.cc/ServerStatusBadge";
|
||||||
|
import { ServerStatusStyledBadge } from "./etke.cc/ServerStatusBadge";
|
||||||
|
|
||||||
|
const AdminUserMenu = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const logout = useLogout();
|
||||||
|
const checkLoginType = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const loginType: LoginMethod = (localStorage.getItem("login_type") || "credentials") as LoginMethod;
|
||||||
|
if (loginType === "accessToken") {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setOpen(false);
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
ClearConfig();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserMenu>
|
||||||
|
<div onClickCapture={checkLoginType}>
|
||||||
|
<Logout />
|
||||||
|
</div>
|
||||||
|
<Confirm
|
||||||
|
isOpen={open}
|
||||||
|
title="synapseadmin.auth.logout_acces_token_dialog.title"
|
||||||
|
content="synapseadmin.auth.logout_acces_token_dialog.content"
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
confirm="synapseadmin.auth.logout_acces_token_dialog.confirm"
|
||||||
|
cancel="synapseadmin.auth.logout_acces_token_dialog.cancel"
|
||||||
|
/>
|
||||||
|
</UserMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdminAppBar = () => {
|
||||||
|
return (
|
||||||
|
<AppBar userMenu={<AdminUserMenu />}>
|
||||||
|
<TitlePortal />
|
||||||
|
<ServerStatusBadge />
|
||||||
|
<ServerNotificationsBadge />
|
||||||
|
<InspectorButton />
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdminMenu = props => {
|
||||||
|
const [menu, setMenu] = useState([] as MenuItem[]);
|
||||||
|
const [etkeRoutesEnabled, setEtkeRoutesEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setMenu(GetConfig().menu);
|
||||||
|
if (GetConfig().etkeccAdmin) {
|
||||||
|
setEtkeRoutesEnabled(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||||
|
command: "",
|
||||||
|
locked_at: "",
|
||||||
|
});
|
||||||
|
const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", {
|
||||||
|
success: false,
|
||||||
|
ok: false,
|
||||||
|
host: "",
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu {...props}>
|
||||||
|
{etkeRoutesEnabled && (
|
||||||
|
<Menu.Item
|
||||||
|
key="server_status"
|
||||||
|
to="/server_status"
|
||||||
|
leftIcon={
|
||||||
|
<ServerStatusStyledBadge
|
||||||
|
inSidebar={true}
|
||||||
|
command={serverProcess.command}
|
||||||
|
locked_at={serverProcess.locked_at}
|
||||||
|
isOkay={serverStatus.ok}
|
||||||
|
isLoaded={serverStatus.success}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
primaryText="Server Status"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{etkeRoutesEnabled && (
|
||||||
|
<Menu.Item
|
||||||
|
key="server_actions"
|
||||||
|
to="/server_actions"
|
||||||
|
leftIcon={<ManageHistoryIcon />}
|
||||||
|
primaryText="Server Actions"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Menu.ResourceItems />
|
||||||
|
{menu &&
|
||||||
|
menu.map((item, index) => {
|
||||||
|
const { url, icon, label } = item;
|
||||||
|
const IconComponent = Icons[icon] as React.ComponentType<any> | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense key={index}>
|
||||||
|
<Menu.Item
|
||||||
|
to={url}
|
||||||
|
target="_blank"
|
||||||
|
primaryText={label}
|
||||||
|
leftIcon={IconComponent ? <IconComponent /> : <DefaultIcon />}
|
||||||
|
onClick={props.onMenuClick}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminLayout = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout
|
||||||
|
appBar={AdminAppBar}
|
||||||
|
menu={AdminMenu}
|
||||||
|
sx={{
|
||||||
|
["& .RaLayout-appFrame"]: {
|
||||||
|
minHeight: "90vh",
|
||||||
|
height: "90vh",
|
||||||
|
},
|
||||||
|
["& .RaLayout-content"]: {
|
||||||
|
marginBottom: "3rem",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckForApplicationUpdate />
|
||||||
|
</Layout>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminLayout;
|
@@ -1,18 +1,44 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { act } from "react";
|
||||||
import { RecordContextProvider } from "react-admin";
|
import { RecordContextProvider } from "react-admin";
|
||||||
|
|
||||||
import AvatarField from "./AvatarField";
|
import AvatarField from "./AvatarField";
|
||||||
|
|
||||||
describe("AvatarField", () => {
|
describe("AvatarField", () => {
|
||||||
it("shows image", () => {
|
beforeEach(() => {
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
blob: () => Promise.resolve(new Blob(["mock image data"], { type: "image/jpeg" })),
|
||||||
|
})
|
||||||
|
) as jest.Mock;
|
||||||
|
|
||||||
|
// Mock URL.createObjectURL
|
||||||
|
global.URL.createObjectURL = jest.fn(() => "mock-object-url");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.only("shows image", async () => {
|
||||||
const value = {
|
const value = {
|
||||||
avatar: "foo",
|
avatar: "mxc://serverName/mediaId",
|
||||||
};
|
};
|
||||||
render(
|
|
||||||
<RecordContextProvider value={value}>
|
await act(async () => {
|
||||||
<AvatarField source="avatar" />
|
render(
|
||||||
</RecordContextProvider>
|
<RecordContextProvider value={value}>
|
||||||
);
|
<AvatarField source="avatar" />
|
||||||
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
|
</RecordContextProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const img = screen.getByRole("img");
|
||||||
|
expect(img.getAttribute("src")).toBe("mock-object-url");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,13 +1,111 @@
|
|||||||
|
import { Avatar, AvatarProps, Badge, Tooltip } from "@mui/material";
|
||||||
import { get } from "lodash";
|
import { get } from "lodash";
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { FieldProps, useRecordContext, useTranslate } from "react-admin";
|
||||||
|
|
||||||
import { Avatar } from "@mui/material";
|
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||||
import { useRecordContext } from "react-admin";
|
import { isMXID, isASManaged } from "../utils/mxid";
|
||||||
|
|
||||||
const AvatarField = ({ source, ...rest }) => {
|
const AvatarField = ({ source, ...rest }: AvatarProps & FieldProps) => {
|
||||||
const record = useRecordContext(rest);
|
|
||||||
const src = get(record, source)?.toString();
|
|
||||||
const { alt, classes, sizes, sx, variant } = rest;
|
const { alt, classes, sizes, sx, variant } = rest;
|
||||||
return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />;
|
const record = useRecordContext(rest);
|
||||||
|
const mxcURL = get(record, source)?.toString();
|
||||||
|
|
||||||
|
const [src, setSrc] = useState<string>("");
|
||||||
|
|
||||||
|
const fetchAvatar = useCallback(async (mxcURL: string) => {
|
||||||
|
const response = await fetchAuthenticatedMedia(mxcURL, "thumbnail");
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobURL = URL.createObjectURL(blob);
|
||||||
|
setSrc(blobURL);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mxcURL) {
|
||||||
|
fetchAvatar(mxcURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function to revoke the object URL
|
||||||
|
return () => {
|
||||||
|
if (src) {
|
||||||
|
URL.revokeObjectURL(src);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mxcURL, fetchAvatar]);
|
||||||
|
|
||||||
|
// a hacky way to handle both users and rooms,
|
||||||
|
// where users have an ID, may have a name, and may have a displayname
|
||||||
|
// and rooms have an ID and may have a name
|
||||||
|
let letter = "";
|
||||||
|
if (record?.id) {
|
||||||
|
letter = record.id[0].toUpperCase();
|
||||||
|
}
|
||||||
|
if (record?.name) {
|
||||||
|
letter = record.name[0].toUpperCase();
|
||||||
|
}
|
||||||
|
if (record?.displayname) {
|
||||||
|
letter = record.displayname[0].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// hacky way to determine the user type
|
||||||
|
let badge = "";
|
||||||
|
let tooltip = "";
|
||||||
|
if (isMXID(record?.id)) {
|
||||||
|
const translate = useTranslate();
|
||||||
|
switch (record?.user_type) {
|
||||||
|
case "bot":
|
||||||
|
badge = "🤖";
|
||||||
|
tooltip = translate("resources.users.badge.bot");
|
||||||
|
break;
|
||||||
|
case "support":
|
||||||
|
badge = "📞";
|
||||||
|
tooltip = translate("resources.users.badge.support");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
badge = "👤";
|
||||||
|
tooltip = translate("resources.users.badge.regular");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (record?.admin) {
|
||||||
|
badge = "👑";
|
||||||
|
tooltip = translate("resources.users.badge.admin");
|
||||||
|
}
|
||||||
|
if (isASManaged(record?.name)) {
|
||||||
|
badge = "🛡️";
|
||||||
|
tooltip = `${translate("resources.users.badge.system_managed")} (${tooltip})`;
|
||||||
|
}
|
||||||
|
if (localStorage.getItem("user_id") === record?.id) {
|
||||||
|
badge = "🧙";
|
||||||
|
tooltip = `${translate("resources.users.badge.you")} (${tooltip})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is a badge, wrap the Avatar in a Badge and a Tooltip
|
||||||
|
if (badge) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltip}>
|
||||||
|
<Badge
|
||||||
|
badgeContent={badge}
|
||||||
|
overlap="circular"
|
||||||
|
sx={{ "& .MuiBadge-badge": { width: "10px" } }} // we deliberately set a very small width here, to make the badge actually circular
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: "right",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant}>
|
||||||
|
{letter}
|
||||||
|
</Avatar>
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant}>
|
||||||
|
{letter}
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AvatarField;
|
export default AvatarField;
|
||||||
|
117
src/components/DeleteRoomButton.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||||
|
import ActionDelete from "@mui/icons-material/Delete";
|
||||||
|
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||||
|
import { Button, Tooltip, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import {
|
||||||
|
SimpleForm,
|
||||||
|
BooleanInput,
|
||||||
|
useTranslate,
|
||||||
|
RaRecord,
|
||||||
|
useNotify,
|
||||||
|
useRedirect,
|
||||||
|
useDelete,
|
||||||
|
NotificationType,
|
||||||
|
useDeleteMany,
|
||||||
|
Identifier,
|
||||||
|
useUnselectAll,
|
||||||
|
} from "react-admin";
|
||||||
|
|
||||||
|
interface DeleteRoomButtonProps {
|
||||||
|
selectedIds: Identifier[];
|
||||||
|
confirmTitle: string;
|
||||||
|
confirmContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceName = "rooms";
|
||||||
|
|
||||||
|
const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = props => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [block, setBlock] = useState(false);
|
||||||
|
|
||||||
|
const notify = useNotify();
|
||||||
|
const redirect = useRedirect();
|
||||||
|
|
||||||
|
const [deleteMany, { isLoading }] = useDeleteMany();
|
||||||
|
const unselectAll = useUnselectAll(resourceName);
|
||||||
|
const recordIds = props.selectedIds;
|
||||||
|
|
||||||
|
const handleDialogOpen = () => setOpen(true);
|
||||||
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
|
const handleDelete = (values: { block: boolean }) => {
|
||||||
|
deleteMany(
|
||||||
|
resourceName,
|
||||||
|
{ ids: recordIds, meta: values },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notify("resources.rooms.action.erase.success");
|
||||||
|
handleDialogClose();
|
||||||
|
unselectAll();
|
||||||
|
redirect("/rooms");
|
||||||
|
},
|
||||||
|
onError: error => notify("resources.rooms.action.erase.failure", { type: "error" as NotificationType }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setOpen(false);
|
||||||
|
handleDelete({ block: block });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Tooltip title={translate("ra.action.delete")}>
|
||||||
|
<Button
|
||||||
|
onClick={handleDialogOpen}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={"ra-delete-button"}
|
||||||
|
key="button"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
"&.MuiButton-sizeSmall": {
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
color={"error"}
|
||||||
|
startIcon={<ActionDelete />}
|
||||||
|
>
|
||||||
|
{translate("ra.action.delete")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Dialog open={open} onClose={handleDialogClose}>
|
||||||
|
<DialogTitle>{translate(props.confirmTitle)}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
|
||||||
|
<SimpleForm toolbar={false}>
|
||||||
|
<BooleanInput
|
||||||
|
source="block"
|
||||||
|
value={block}
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)}
|
||||||
|
label="resources.rooms.action.erase.fields.block"
|
||||||
|
defaultValue={false}
|
||||||
|
/>
|
||||||
|
</SimpleForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
|
||||||
|
{translate("ra.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={false}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={"ra-confirm RaConfirm-confirmPrimary"}
|
||||||
|
autoFocus
|
||||||
|
startIcon={<ActionCheck />}
|
||||||
|
>
|
||||||
|
{translate("ra.action.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteRoomButton;
|
130
src/components/DeleteUserButton.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||||
|
import ActionDelete from "@mui/icons-material/Delete";
|
||||||
|
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||||
|
import { Tooltip, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import {
|
||||||
|
SimpleForm,
|
||||||
|
BooleanInput,
|
||||||
|
useTranslate,
|
||||||
|
RaRecord,
|
||||||
|
useNotify,
|
||||||
|
useRedirect,
|
||||||
|
useDelete,
|
||||||
|
NotificationType,
|
||||||
|
useDeleteMany,
|
||||||
|
Identifier,
|
||||||
|
useUnselectAll,
|
||||||
|
} from "react-admin";
|
||||||
|
|
||||||
|
interface DeleteUserButtonProps {
|
||||||
|
selectedIds: Identifier[];
|
||||||
|
confirmTitle: string;
|
||||||
|
confirmContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceName = "users";
|
||||||
|
|
||||||
|
const DeleteUserButton: React.FC<DeleteUserButtonProps> = props => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [deleteMedia, setDeleteMedia] = useState(false);
|
||||||
|
const [redactEvents, setRedactEvents] = useState(false);
|
||||||
|
|
||||||
|
const notify = useNotify();
|
||||||
|
const redirect = useRedirect();
|
||||||
|
|
||||||
|
const [deleteMany, { isLoading }] = useDeleteMany();
|
||||||
|
const unselectAll = useUnselectAll(resourceName);
|
||||||
|
const recordIds = props.selectedIds;
|
||||||
|
|
||||||
|
const handleDialogOpen = () => setOpen(true);
|
||||||
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
|
const handleDelete = (values: { deleteMedia: boolean; redactEvents: boolean }) => {
|
||||||
|
deleteMany(
|
||||||
|
resourceName,
|
||||||
|
{ ids: recordIds, meta: values },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notify("ra.notification.deleted", {
|
||||||
|
messageArgs: {
|
||||||
|
smart_count: recordIds.length,
|
||||||
|
},
|
||||||
|
type: "info" as NotificationType,
|
||||||
|
});
|
||||||
|
handleDialogClose();
|
||||||
|
unselectAll();
|
||||||
|
redirect("/users");
|
||||||
|
},
|
||||||
|
onError: error => notify("ra.notification.data_provider_error", { type: "error" as NotificationType }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setOpen(false);
|
||||||
|
handleDelete({ deleteMedia: deleteMedia, redactEvents: redactEvents });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Tooltip title={translate("ra.action.delete")}>
|
||||||
|
<Button
|
||||||
|
onClick={handleDialogOpen}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={"ra-delete-button"}
|
||||||
|
key="button"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
"&.MuiButton-sizeSmall": {
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
color={"error"}
|
||||||
|
startIcon={<ActionDelete />}
|
||||||
|
>
|
||||||
|
{translate("ra.action.delete")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Dialog open={open} onClose={handleDialogClose}>
|
||||||
|
<DialogTitle>{translate(props.confirmTitle)}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
|
||||||
|
<SimpleForm toolbar={false}>
|
||||||
|
<BooleanInput
|
||||||
|
source="deleteMedia"
|
||||||
|
value={deleteMedia}
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDeleteMedia(event.target.checked)}
|
||||||
|
label="resources.users.action.delete_media"
|
||||||
|
defaultValue={false}
|
||||||
|
/>
|
||||||
|
<BooleanInput
|
||||||
|
source="redactEvents"
|
||||||
|
value={redactEvents}
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setRedactEvents(event.target.checked)}
|
||||||
|
label="resources.users.action.redact_events"
|
||||||
|
defaultValue={false}
|
||||||
|
/>
|
||||||
|
</SimpleForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
|
||||||
|
{translate("ra.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={false}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={"ra-confirm RaConfirm-confirmPrimary"}
|
||||||
|
autoFocus
|
||||||
|
startIcon={<ActionCheck />}
|
||||||
|
>
|
||||||
|
{translate("ra.action.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteUserButton;
|
38
src/components/DeviceRemoveButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Tooltip } from "@mui/material";
|
||||||
|
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
|
||||||
|
import { useTranslate } from "react-admin";
|
||||||
|
|
||||||
|
import { isASManaged } from "../utils/mxid";
|
||||||
|
|
||||||
|
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
const record = useRecordContext();
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
let isASManagedUser = false;
|
||||||
|
if (record.user_id) {
|
||||||
|
isASManagedUser = isASManaged(record.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={isASManagedUser ? translate("resources.devices.action.erase.disabled") : translate("ra.action.delete")}
|
||||||
|
>
|
||||||
|
<DeleteWithConfirmButton
|
||||||
|
{...props}
|
||||||
|
label="ra.action.remove"
|
||||||
|
confirmTitle="resources.devices.action.erase.title"
|
||||||
|
confirmContent="resources.devices.action.erase.content"
|
||||||
|
mutationMode="pessimistic"
|
||||||
|
redirect={false}
|
||||||
|
disabled={isASManagedUser}
|
||||||
|
translateOptions={{
|
||||||
|
id: record.id,
|
||||||
|
name: record.display_name ? record.display_name : record.id,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeviceRemoveButton;
|
101
src/components/ExperimentalFeatures.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Tooltip, Stack, Switch, Typography } from "@mui/material";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRecordContext, useTranslate, useNotify, useDataProvider } from "react-admin";
|
||||||
|
|
||||||
|
import { ExperimentalFeaturesModel, SynapseDataProvider } from "../synapse/dataProvider";
|
||||||
|
|
||||||
|
const experimentalFeaturesMap = {
|
||||||
|
msc3881: "enable remotely toggling push notifications for another client",
|
||||||
|
msc3575: "enable experimental sliding sync support",
|
||||||
|
};
|
||||||
|
const ExperimentalFeatureRow = (props: {
|
||||||
|
featureKey: string;
|
||||||
|
featureValue: boolean;
|
||||||
|
updateFeature: (feature_name: string, feature_value: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
const featureKey = props.featureKey;
|
||||||
|
const featureValue = props.featureValue;
|
||||||
|
const featureDescription = experimentalFeaturesMap[featureKey] ?? "";
|
||||||
|
const [checked, setChecked] = useState(featureValue);
|
||||||
|
|
||||||
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setChecked(event.target.checked);
|
||||||
|
props.updateFeature(featureKey, event.target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
alignItems="start"
|
||||||
|
sx={{
|
||||||
|
padding: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title={translate("resources.experimental_features.action.toggle")}>
|
||||||
|
<Switch checked={checked} onChange={handleChange} />
|
||||||
|
</Tooltip>
|
||||||
|
<Stack>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "medium",
|
||||||
|
color: "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{featureKey}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{featureDescription}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExperimentalFeaturesList = () => {
|
||||||
|
const record = useRecordContext();
|
||||||
|
const notify = useNotify();
|
||||||
|
const dataProvider = useDataProvider() as SynapseDataProvider;
|
||||||
|
const [features, setFeatures] = useState({});
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFeatures = async () => {
|
||||||
|
const features = await dataProvider.getFeatures(record.id);
|
||||||
|
setFeatures(features);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFeatures();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateFeature = async (feature_name: string, feature_value: boolean) => {
|
||||||
|
const updatedFeatures = { ...features, [feature_name]: feature_value } as ExperimentalFeaturesModel;
|
||||||
|
setFeatures(updatedFeatures);
|
||||||
|
const reponse = await dataProvider.updateFeatures(record.id, updatedFeatures);
|
||||||
|
notify("ra.notification.updated", {
|
||||||
|
messageArgs: { smart_count: 1 },
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack direction="column" spacing={1}>
|
||||||
|
{Object.keys(features).map((featureKey: string) => (
|
||||||
|
<ExperimentalFeatureRow
|
||||||
|
key={featureKey}
|
||||||
|
featureKey={featureKey}
|
||||||
|
featureValue={features[featureKey]}
|
||||||
|
updateFeature={updateFeature}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExperimentalFeaturesList;
|
59
src/components/Footer.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Avatar, Box, Link, Typography } from "@mui/material";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const version = document.getElementById("js-version")?.textContent;
|
||||||
|
if (version) {
|
||||||
|
setVersion(version);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
position: "fixed",
|
||||||
|
zIndex: 100,
|
||||||
|
bottom: 0,
|
||||||
|
width: "100%",
|
||||||
|
bgcolor: theme.palette.background.default,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
borderTop: "1px solid",
|
||||||
|
borderColor: theme.palette.divider,
|
||||||
|
fontSize: "0.89rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "start",
|
||||||
|
p: 1,
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src="./images/logo.webp"
|
||||||
|
sx={{ width: "1rem", height: "1rem", display: "inline-block", verticalAlign: "sub" }}
|
||||||
|
/>
|
||||||
|
<Link href="https://github.com/etkecc/synapse-admin" target="_blank">
|
||||||
|
Synapse Admin {version}
|
||||||
|
</Link>
|
||||||
|
by
|
||||||
|
<Link
|
||||||
|
href="https://etke.cc/?utm_source=synapse-admin&utm_medium=footer&utm_campaign=synapse-admin"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
etke.cc
|
||||||
|
</Link>
|
||||||
|
(originally developed by Awesome Technologies Innovationslabor GmbH).
|
||||||
|
<Link sx={{ fontWeight: "bold" }} href="https://matrix.to/#/#synapse-admin:etke.cc" target="_blank">
|
||||||
|
#synapse-admin:etke.cc
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
@@ -1,559 +0,0 @@
|
|||||||
import { parse as parseCsv, unparse as unparseCsv, ParseResult } from "papaparse";
|
|
||||||
import { ChangeEvent, useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardActions,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
Checkbox,
|
|
||||||
Container,
|
|
||||||
FormControlLabel,
|
|
||||||
NativeSelect,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { DataProvider, useTranslate } from "ra-core";
|
|
||||||
import { useDataProvider, useNotify, RaRecord, Title } from "react-admin";
|
|
||||||
|
|
||||||
import { generateRandomMxId, generateRandomPassword } from "../synapse/synapse";
|
|
||||||
|
|
||||||
const LOGGING = true;
|
|
||||||
|
|
||||||
const expectedFields = ["id", "displayname"].sort();
|
|
||||||
|
|
||||||
function TranslatableOption({ value, text }) {
|
|
||||||
const translate = useTranslate();
|
|
||||||
return <option value={value}>{translate(text)}</option>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Progress = {
|
|
||||||
done: number;
|
|
||||||
limit: number;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
interface ImportLine {
|
|
||||||
id: string;
|
|
||||||
displayname: string;
|
|
||||||
user_type?: string;
|
|
||||||
name?: string;
|
|
||||||
deactivated?: boolean;
|
|
||||||
guest?: boolean;
|
|
||||||
admin?: boolean;
|
|
||||||
is_admin?: boolean;
|
|
||||||
password?: string;
|
|
||||||
avatar_url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChangeStats {
|
|
||||||
total: number;
|
|
||||||
id: number;
|
|
||||||
is_guest: number;
|
|
||||||
admin: number;
|
|
||||||
password: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportResult {
|
|
||||||
skippedRecords: RaRecord[];
|
|
||||||
erroredRecords: RaRecord[];
|
|
||||||
succeededRecords: RaRecord[];
|
|
||||||
totalRecordCount: number;
|
|
||||||
changeStats: ChangeStats;
|
|
||||||
wasDryRun: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FilePicker = () => {
|
|
||||||
const [values, setValues] = useState<ImportLine[]>([]);
|
|
||||||
const [error, setError] = useState<string | string[] | null>(null);
|
|
||||||
const [stats, setStats] = useState<ChangeStats | null>(null);
|
|
||||||
const [dryRun, setDryRun] = useState(true);
|
|
||||||
|
|
||||||
const [progress, setProgress] = useState<Progress>(null);
|
|
||||||
|
|
||||||
const [importResults, setImportResults] = useState<ImportResult | null>(null);
|
|
||||||
const [skippedRecords, setSkippedRecords] = useState<string>("");
|
|
||||||
|
|
||||||
const [conflictMode, setConflictMode] = useState("stop");
|
|
||||||
const [passwordMode, setPasswordMode] = useState(true);
|
|
||||||
const [useridMode, setUseridMode] = useState("ignore");
|
|
||||||
|
|
||||||
const translate = useTranslate();
|
|
||||||
const notify = useNotify();
|
|
||||||
|
|
||||||
const dataProvider = useDataProvider();
|
|
||||||
|
|
||||||
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (progress !== null) return;
|
|
||||||
|
|
||||||
setValues([]);
|
|
||||||
setError(null);
|
|
||||||
setStats(null);
|
|
||||||
setImportResults(null);
|
|
||||||
const file = e.target.files ? e.target.files[0] : null;
|
|
||||||
if (!file) return;
|
|
||||||
/* Let's refuse some unreasonably big files instead of freezing
|
|
||||||
* up the browser */
|
|
||||||
if (file.size > 100000000) {
|
|
||||||
const message = translate("import_users.errors.unreasonably_big", {
|
|
||||||
size: (file.size / (1024 * 1024)).toFixed(2),
|
|
||||||
});
|
|
||||||
notify(message);
|
|
||||||
setError(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
parseCsv<ImportLine>(file, {
|
|
||||||
header: true,
|
|
||||||
skipEmptyLines: true /* especially for a final EOL in the csv file */,
|
|
||||||
complete: result => {
|
|
||||||
if (result.errors) {
|
|
||||||
setError(result.errors.map(e => e.toString()));
|
|
||||||
}
|
|
||||||
/* Papaparse is very lenient, we may be able to salvage
|
|
||||||
* the data in the file. */
|
|
||||||
verifyCsv(result, { setValues, setStats, setError });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setError("Unknown error");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>, { setValues, setStats, setError }) => {
|
|
||||||
/* First, verify the presence of required fields */
|
|
||||||
const missingFields = expectedFields.filter(eF => !meta.fields?.includes(eF));
|
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
|
||||||
setError(translate("import_users.error.required_field", { field: missingFields[0] }));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// XXX after deciding on how "name" and friends should be handled below,
|
|
||||||
// this place will want changes, too.
|
|
||||||
|
|
||||||
/* Collect some stats to prevent sneaky csv files from adding admin
|
|
||||||
users or something.
|
|
||||||
*/
|
|
||||||
const stats = {
|
|
||||||
user_types: { default: 0 },
|
|
||||||
is_guest: 0,
|
|
||||||
admin: 0,
|
|
||||||
deactivated: 0,
|
|
||||||
password: 0,
|
|
||||||
avatar_url: 0,
|
|
||||||
id: 0,
|
|
||||||
|
|
||||||
total: data.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorMessages = errors.map(e => e.message);
|
|
||||||
data.forEach((line, idx) => {
|
|
||||||
if (line.user_type === undefined || line.user_type === "") {
|
|
||||||
stats.user_types.default++;
|
|
||||||
} else {
|
|
||||||
stats.user_types[line.user_type] += 1;
|
|
||||||
}
|
|
||||||
/* XXX correct the csv export that react-admin offers for the users
|
|
||||||
* resource so it gives sensible field names and doesn't duplicate
|
|
||||||
* id as "name"?
|
|
||||||
*/
|
|
||||||
if (meta.fields?.includes("name")) {
|
|
||||||
delete line.name;
|
|
||||||
}
|
|
||||||
if (meta.fields?.includes("user_type")) {
|
|
||||||
delete line.user_type;
|
|
||||||
}
|
|
||||||
if (meta.fields?.includes("is_admin")) {
|
|
||||||
delete line.is_admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
["is_guest", "admin", "deactivated"].forEach(f => {
|
|
||||||
if (line[f] === "true") {
|
|
||||||
stats[f]++;
|
|
||||||
line[f] = true; // we need true booleans instead of strings
|
|
||||||
} else {
|
|
||||||
if (line[f] !== "false" && line[f] !== "") {
|
|
||||||
errorMessages.push(
|
|
||||||
translate("import_users.error.invalid_value", {
|
|
||||||
field: f,
|
|
||||||
row: idx,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
line[f] = false; // default values to false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (line.password !== undefined && line.password !== "") {
|
|
||||||
stats.password++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.avatar_url !== undefined && line.avatar_url !== "") {
|
|
||||||
stats.avatar_url++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.id !== undefined && line.id !== "") {
|
|
||||||
stats.id++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (errorMessages.length > 0) {
|
|
||||||
setError(errorMessages);
|
|
||||||
}
|
|
||||||
setStats(stats);
|
|
||||||
setValues(data);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runImport = async () => {
|
|
||||||
if (progress !== null) {
|
|
||||||
notify("import_users.errors.already_in_progress");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await doImport(
|
|
||||||
dataProvider,
|
|
||||||
values,
|
|
||||||
conflictMode,
|
|
||||||
passwordMode,
|
|
||||||
useridMode,
|
|
||||||
dryRun,
|
|
||||||
setProgress,
|
|
||||||
setError
|
|
||||||
);
|
|
||||||
setImportResults(results);
|
|
||||||
// offer CSV download of skipped or errored records
|
|
||||||
// (so that the user doesn't have to filter out successful
|
|
||||||
// records manually when fixing stuff in the CSV)
|
|
||||||
setSkippedRecords(unparseCsv(results.skippedRecords));
|
|
||||||
if (LOGGING) console.log("Skipped records:");
|
|
||||||
if (LOGGING) console.log(skippedRecords);
|
|
||||||
};
|
|
||||||
|
|
||||||
// XXX every single one of the requests will restart the activity indicator
|
|
||||||
// which doesn't look very good.
|
|
||||||
|
|
||||||
const doImport = async (
|
|
||||||
dataProvider: DataProvider,
|
|
||||||
data: ImportLine[],
|
|
||||||
conflictMode: string,
|
|
||||||
passwordMode: boolean,
|
|
||||||
useridMode: string,
|
|
||||||
dryRun: boolean,
|
|
||||||
setProgress: (progress: Progress) => void,
|
|
||||||
setError: (message: string) => void
|
|
||||||
): Promise<ImportResult> => {
|
|
||||||
const skippedRecords: ImportLine[] = [];
|
|
||||||
const erroredRecords: ImportLine[] = [];
|
|
||||||
const succeededRecords: ImportLine[] = [];
|
|
||||||
const changeStats: ChangeStats = {
|
|
||||||
total: 0,
|
|
||||||
id: 0,
|
|
||||||
is_guest: 0,
|
|
||||||
admin: 0,
|
|
||||||
password: 0,
|
|
||||||
};
|
|
||||||
let entriesDone = 0;
|
|
||||||
const entriesCount = data.length;
|
|
||||||
try {
|
|
||||||
setProgress({ done: entriesDone, limit: entriesCount });
|
|
||||||
for (const entry of data) {
|
|
||||||
const userRecord = { ...entry };
|
|
||||||
// No need to do a bunch of cryptographic random number getting if
|
|
||||||
// we are using neither a generated password nor a generated user id.
|
|
||||||
if (useridMode === "ignore" || userRecord.id === undefined) {
|
|
||||||
userRecord.id = generateRandomMxId();
|
|
||||||
}
|
|
||||||
if (passwordMode === false || entry.password === undefined) {
|
|
||||||
userRecord.password = generateRandomPassword();
|
|
||||||
}
|
|
||||||
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
|
|
||||||
|
|
||||||
/* For these modes we will consider the ID that's in the record.
|
|
||||||
* If the mode is "stop", we will not continue adding more records, and
|
|
||||||
* we will offer information on what was already added and what was
|
|
||||||
* skipped.
|
|
||||||
*
|
|
||||||
* If the mode is "skip", we record the record for later, but don't
|
|
||||||
* send it to the server.
|
|
||||||
*
|
|
||||||
* If the mode is "update", we change fields that are reasonable to
|
|
||||||
* update.
|
|
||||||
* - If the "password mode" is "true" (i.e. "use passwords from csv"):
|
|
||||||
* - if the record has a password
|
|
||||||
* - send the password along with the record
|
|
||||||
* - if the record has no password
|
|
||||||
* - generate a new password
|
|
||||||
* - If the "password mode" is "false"
|
|
||||||
* - never generate a new password to update existing users with
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* We just act as if there are no IDs in the CSV, so every user will be
|
|
||||||
* created anew.
|
|
||||||
* We do a simple retry loop so that an accidental hit on an existing ID
|
|
||||||
* doesn't trip us up.
|
|
||||||
*/
|
|
||||||
if (LOGGING) console.log("will check for existence of record " + JSON.stringify(userRecord));
|
|
||||||
let retries = 0;
|
|
||||||
const submitRecord = (recordData: ImportLine) => {
|
|
||||||
return dataProvider.getOne("users", { id: recordData.id }).then(
|
|
||||||
async () => {
|
|
||||||
if (LOGGING) console.log("already existed");
|
|
||||||
|
|
||||||
if (useridMode === "update" || conflictMode === "skip") {
|
|
||||||
skippedRecords.push(recordData);
|
|
||||||
} else if (conflictMode === "stop") {
|
|
||||||
throw new Error(
|
|
||||||
translate("import_users.error.id_exits", {
|
|
||||||
id: recordData.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const newRecordData = Object.assign({}, recordData, {
|
|
||||||
id: generateRandomMxId(),
|
|
||||||
});
|
|
||||||
retries++;
|
|
||||||
if (retries > 512) {
|
|
||||||
console.warn("retry loop got stuck? pathological situation?");
|
|
||||||
skippedRecords.push(recordData);
|
|
||||||
} else {
|
|
||||||
await submitRecord(newRecordData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
if (LOGGING) console.log("OK to create record " + recordData.id + " (" + recordData.displayname + ").");
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
await dataProvider.create("users", { data: recordData });
|
|
||||||
}
|
|
||||||
succeededRecords.push(recordData);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
await submitRecord(userRecord);
|
|
||||||
entriesDone++;
|
|
||||||
setProgress({ done: entriesDone, limit: data.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(null);
|
|
||||||
} catch (e) {
|
|
||||||
setError(
|
|
||||||
translate("import_users.error.at_entry", {
|
|
||||||
entry: entriesDone + 1,
|
|
||||||
message: e instanceof Error ? e.message : String(e),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setProgress(null);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
skippedRecords,
|
|
||||||
erroredRecords,
|
|
||||||
succeededRecords,
|
|
||||||
totalRecordCount: entriesCount,
|
|
||||||
changeStats,
|
|
||||||
wasDryRun: dryRun,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadSkippedRecords = () => {
|
|
||||||
const element = document.createElement("a");
|
|
||||||
console.log(skippedRecords);
|
|
||||||
const file = new Blob([skippedRecords], {
|
|
||||||
type: "text/comma-separated-values",
|
|
||||||
});
|
|
||||||
element.href = URL.createObjectURL(file);
|
|
||||||
element.download = "skippedRecords.csv";
|
|
||||||
document.body.appendChild(element); // Required for this to work in FireFox
|
|
||||||
element.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onConflictModeChanged = async (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
if (progress !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = e.target.value;
|
|
||||||
setConflictMode(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPasswordModeChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (progress !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPasswordMode(e.target.checked);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUseridModeChanged = async (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
if (progress !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = e.target.value;
|
|
||||||
setUseridMode(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDryRunModeChanged = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (progress !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDryRun(e.target.checked);
|
|
||||||
};
|
|
||||||
|
|
||||||
// render individual small components
|
|
||||||
|
|
||||||
const statsCards = stats &&
|
|
||||||
!importResults && [
|
|
||||||
<Container>
|
|
||||||
<CardHeader title={translate("import_users.cards.importstats.header")} />
|
|
||||||
<CardContent>
|
|
||||||
<div>{translate("import_users.cards.importstats.users_total", stats.total)}</div>
|
|
||||||
<div>{translate("import_users.cards.importstats.guest_count", stats.is_guest)}</div>
|
|
||||||
<div>{translate("import_users.cards.importstats.admin_count", stats.admin)}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Container>,
|
|
||||||
<Container>
|
|
||||||
<CardHeader title={translate("import_users.cards.ids.header")} />
|
|
||||||
<CardContent>
|
|
||||||
<div>
|
|
||||||
{stats.id === stats.total
|
|
||||||
? translate("import_users.cards.ids.all_ids_present")
|
|
||||||
: translate("import_users.cards.ids.count_ids_present", stats.id)}
|
|
||||||
</div>
|
|
||||||
{stats.id > 0 ? (
|
|
||||||
<div>
|
|
||||||
<NativeSelect onChange={onUseridModeChanged} value={useridMode} disabled={progress !== null}>
|
|
||||||
<TranslatableOption value="ignore" text="import_users.cards.ids.mode.ignore" />
|
|
||||||
<TranslatableOption value="update" text="import_users.cards.ids.mode.update" />
|
|
||||||
</NativeSelect>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Container>,
|
|
||||||
<Container>
|
|
||||||
<CardHeader title={translate("import_users.cards.passwords.header")} />
|
|
||||||
<CardContent>
|
|
||||||
<div>
|
|
||||||
{stats.password === stats.total
|
|
||||||
? translate("import_users.cards.passwords.all_passwords_present")
|
|
||||||
: translate("import_users.cards.passwords.count_passwords_present", stats.password)}
|
|
||||||
</div>
|
|
||||||
{stats.password > 0 ? (
|
|
||||||
<div>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox checked={passwordMode} disabled={progress !== null} onChange={onPasswordModeChange} />
|
|
||||||
}
|
|
||||||
label={translate("import_users.cards.passwords.use_passwords")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Container>,
|
|
||||||
];
|
|
||||||
|
|
||||||
const conflictCards = stats && !importResults && (
|
|
||||||
<Container>
|
|
||||||
<CardHeader title={translate("import_users.cards.conflicts.header")} />
|
|
||||||
<CardContent>
|
|
||||||
<div>
|
|
||||||
<NativeSelect onChange={onConflictModeChanged} value={conflictMode} disabled={progress !== null}>
|
|
||||||
<TranslatableOption value="stop" text="import_users.cards.conflicts.mode.stop" />
|
|
||||||
<TranslatableOption value="skip" text="import_users.cards.conflicts.mode.skip" />
|
|
||||||
</NativeSelect>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
|
|
||||||
const errorCards = error && (
|
|
||||||
<Container>
|
|
||||||
<CardHeader title={translate("import_users.error.error")} />
|
|
||||||
<CardContent>
|
|
||||||
{(Array.isArray(error) ? error : [error]).map(e => (
|
|
||||||
<div>{e}</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
|
|
||||||
const uploadCard = !importResults && (
|
|
||||||
<Container>
|
|
||||||
<CardHeader title={translate("import_users.cards.upload.header")} />
|
|
||||||
<CardContent>
|
|
||||||
{translate("import_users.cards.upload.explanation")}
|
|
||||||
<a href="./data/example.csv">example.csv</a>
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<input type="file" onChange={onFileChange} disabled={progress !== null} />
|
|
||||||
</CardContent>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
|
|
||||||
const resultsCard = importResults && (
|
|
||||||
<CardContent>
|
|
||||||
<CardHeader title={translate("import_users.cards.results.header")} />
|
|
||||||
<div>
|
|
||||||
{translate("import_users.cards.results.total", importResults.totalRecordCount)}
|
|
||||||
<br />
|
|
||||||
{translate("import_users.cards.results.successful", importResults.succeededRecords.length)}
|
|
||||||
<br />
|
|
||||||
{importResults.skippedRecords.length
|
|
||||||
? [
|
|
||||||
translate("import_users.cards.results.skipped", importResults.skippedRecords.length),
|
|
||||||
<div>
|
|
||||||
<button onClick={downloadSkippedRecords}>
|
|
||||||
{translate("import_users.cards.results.download_skipped")}
|
|
||||||
</button>
|
|
||||||
</div>,
|
|
||||||
<br />,
|
|
||||||
]
|
|
||||||
: ""}
|
|
||||||
{importResults.erroredRecords.length
|
|
||||||
? [translate("import_users.cards.results.skipped", importResults.erroredRecords.length), <br />]
|
|
||||||
: ""}
|
|
||||||
<br />
|
|
||||||
{importResults.wasDryRun && [translate("import_users.cards.results.simulated_only"), <br />]}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
);
|
|
||||||
|
|
||||||
const startImportCard =
|
|
||||||
!values || values.length === 0 || importResults ? undefined : (
|
|
||||||
<CardActions>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox checked={dryRun} onChange={onDryRunModeChanged} disabled={progress !== null} />}
|
|
||||||
label={translate("import_users.cards.startImport.simulate_only")}
|
|
||||||
/>
|
|
||||||
<Button size="large" onClick={runImport} disabled={progress !== null}>
|
|
||||||
{translate("import_users.cards.startImport.run_import")}
|
|
||||||
</Button>
|
|
||||||
{progress !== null ? (
|
|
||||||
<div>
|
|
||||||
{progress.done} of {progress.limit} done
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardActions>
|
|
||||||
);
|
|
||||||
|
|
||||||
const allCards: JSX.Element[] = [];
|
|
||||||
if (uploadCard) allCards.push(uploadCard);
|
|
||||||
if (errorCards) allCards.push(errorCards);
|
|
||||||
if (conflictCards) allCards.push(conflictCards);
|
|
||||||
if (statsCards) allCards.push(...statsCards);
|
|
||||||
if (startImportCard) allCards.push(startImportCard);
|
|
||||||
if (resultsCard) allCards.push(resultsCard);
|
|
||||||
|
|
||||||
const cardContainer = <Card>{allCards}</Card>;
|
|
||||||
|
|
||||||
return [<Title defaultTitle={translate("import_users.title")} />, cardContainer];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ImportFeature = FilePicker;
|
|
58
src/components/LoginFormBox.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
|
||||||
|
const LoginFormBox = styled(Box)(({ theme }) => ({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: "calc(100vh - 1rem)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
background: "url(./images/floating-cogs.svg)",
|
||||||
|
backgroundColor: theme.palette.mode === "dark" ? theme.palette.background.default : theme.palette.background.paper,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: "cover",
|
||||||
|
|
||||||
|
[`& .card`]: {
|
||||||
|
width: "30rem",
|
||||||
|
marginTop: "6rem",
|
||||||
|
marginBottom: "6rem",
|
||||||
|
},
|
||||||
|
[`& .avatar`]: {
|
||||||
|
margin: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
[`& .icon`]: {
|
||||||
|
backgroundColor: theme.palette.grey[500],
|
||||||
|
},
|
||||||
|
[`& .hint`]: {
|
||||||
|
marginTop: "1em",
|
||||||
|
marginBottom: "1em",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: theme.palette.grey[600],
|
||||||
|
},
|
||||||
|
[`& .form`]: {
|
||||||
|
padding: "0 1rem 1rem 1rem",
|
||||||
|
},
|
||||||
|
[`& .select`]: {
|
||||||
|
marginBottom: "2rem",
|
||||||
|
},
|
||||||
|
[`& .actions`]: {
|
||||||
|
padding: "0 1rem 1rem 1rem",
|
||||||
|
},
|
||||||
|
[`& .serverVersion`]: {
|
||||||
|
color: theme.palette.grey[500],
|
||||||
|
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
},
|
||||||
|
[`& .matrixVersions`]: {
|
||||||
|
color: theme.palette.grey[500],
|
||||||
|
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default LoginFormBox;
|
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import IconCancel from "@mui/icons-material/Cancel";
|
import IconCancel from "@mui/icons-material/Cancel";
|
||||||
import MessageIcon from "@mui/icons-material/Message";
|
import MessageIcon from "@mui/icons-material/Message";
|
||||||
import { Dialog, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
RaRecord,
|
RaRecord,
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
useTranslate,
|
useTranslate,
|
||||||
useUnselectAll,
|
useUnselectAll,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
@@ -64,7 +63,7 @@ export const ServerNoticeButton = () => {
|
|||||||
const handleDialogClose = () => setOpen(false);
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = (values: Partial<RaRecord>) => {
|
const handleSend = (values: Partial<RaRecord>) => {
|
||||||
@@ -104,7 +103,7 @@ export const ServerNoticeBulkButton = () => {
|
|||||||
const dataProvider = useDataProvider();
|
const dataProvider = useDataProvider();
|
||||||
|
|
||||||
const { mutate: sendNotices, isPending } = useMutation({
|
const { mutate: sendNotices, isPending } = useMutation({
|
||||||
mutationFn: (data) =>
|
mutationFn: data =>
|
||||||
dataProvider.createMany("servernotices", {
|
dataProvider.createMany("servernotices", {
|
||||||
ids: selectedIds,
|
ids: selectedIds,
|
||||||
data: data,
|
data: data,
|
||||||
|
68
src/components/UserAccountData.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||||
|
import { Typography, Box, Stack, Accordion, AccordionSummary, AccordionDetails } from "@mui/material";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useDataProvider, useRecordContext, useTranslate } from "react-admin";
|
||||||
|
|
||||||
|
import { SynapseDataProvider } from "../synapse/dataProvider";
|
||||||
|
|
||||||
|
const UserAccountData = () => {
|
||||||
|
const dataProvider = useDataProvider() as SynapseDataProvider;
|
||||||
|
const record = useRecordContext();
|
||||||
|
const translate = useTranslate();
|
||||||
|
const [globalAccountData, setGlobalAccountData] = useState({});
|
||||||
|
const [roomsAccountData, setRoomsAccountData] = useState({});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAccountData = async () => {
|
||||||
|
const accountData = await dataProvider.getAccountData(record.id);
|
||||||
|
setGlobalAccountData(accountData.account_data.global);
|
||||||
|
setRoomsAccountData(accountData.account_data.rooms);
|
||||||
|
};
|
||||||
|
fetchAccountData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (Object.keys(globalAccountData).length === 0 && Object.keys(roomsAccountData).length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography variant="body2">
|
||||||
|
{translate("ra.navigation.no_results", {
|
||||||
|
resource: "Account Data",
|
||||||
|
_: "No results found.",
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack direction="column" spacing={2} width="100%">
|
||||||
|
<Typography variant="h6">{translate("resources.users.account_data.title")}</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
<Box>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ArrowDownwardIcon />}>
|
||||||
|
<Typography variant="h6">{translate("resources.users.account_data.global")}</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box sx={{ whiteSpace: "pre-wrap" }}>{JSON.stringify(globalAccountData, null, 4)}</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ArrowDownwardIcon />}>
|
||||||
|
<Typography variant="h6">{translate("resources.users.account_data.rooms")}</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box sx={{ whiteSpace: "pre-wrap" }}>{JSON.stringify(roomsAccountData, null, 4)}</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserAccountData;
|
99
src/components/UserRateLimits.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Stack, Typography } from "@mui/material";
|
||||||
|
import { TextField } from "@mui/material";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin";
|
||||||
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
const RateLimitRow = ({
|
||||||
|
limit,
|
||||||
|
value,
|
||||||
|
updateRateLimit,
|
||||||
|
}: {
|
||||||
|
limit: string;
|
||||||
|
value: any;
|
||||||
|
updateRateLimit: (limit: string, value: any) => void;
|
||||||
|
}) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
|
||||||
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = parseInt(event.target.value);
|
||||||
|
if (isNaN(value)) {
|
||||||
|
updateRateLimit(limit, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateRateLimit(limit, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
spacing={1}
|
||||||
|
alignItems="start"
|
||||||
|
sx={{
|
||||||
|
padding: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
id="outlined-number"
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
slotProps={{
|
||||||
|
inputLabel: {
|
||||||
|
shrink: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
label={translate(`resources.users.limits.${limit}`)}
|
||||||
|
/>
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{translate(`resources.users.limits.${limit}_text`)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserRateLimits = () => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
const notify = useNotify();
|
||||||
|
const record = useRecordContext();
|
||||||
|
const form = useFormContext();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const [rateLimits, setRateLimits] = useState({
|
||||||
|
messages_per_second: "", // we are setting string here to make the number field empty by default, null is prohibited by the field validation
|
||||||
|
burst_count: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRateLimits = async () => {
|
||||||
|
const rateLimits = await dataProvider.getRateLimits(record.id);
|
||||||
|
if (Object.keys(rateLimits).length > 0) {
|
||||||
|
setRateLimits(rateLimits);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRateLimits();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateRateLimit = async (limit: string, value: any) => {
|
||||||
|
const updatedRateLimits = { ...rateLimits, [limit]: value };
|
||||||
|
setRateLimits(updatedRateLimits);
|
||||||
|
form.setValue(`rates.${limit}`, value, { shouldDirty: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack direction="column">
|
||||||
|
{Object.keys(rateLimits).map((limit: string) => (
|
||||||
|
<RateLimitRow key={limit} limit={limit} value={rateLimits[limit]} updateRateLimit={updateRateLimit} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserRateLimits;
|
@@ -1,28 +0,0 @@
|
|||||||
export const DATE_FORMAT: Intl.DateTimeFormatOptions = {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dateParser = (v: string | number | Date): number => {
|
|
||||||
const d = new Date(v);
|
|
||||||
return d.getTime();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dateFormatter = (v: string | number | Date | undefined | null): string => {
|
|
||||||
if (v === undefined || v === null) return "";
|
|
||||||
const d = new Date(v);
|
|
||||||
|
|
||||||
const pad = "00";
|
|
||||||
const year = d.getFullYear().toString();
|
|
||||||
const month = (pad + (d.getMonth() + 1).toString()).slice(-2);
|
|
||||||
const day = (pad + d.getDate().toString()).slice(-2);
|
|
||||||
const hour = (pad + d.getHours().toString()).slice(-2);
|
|
||||||
const minute = (pad + d.getMinutes().toString()).slice(-2);
|
|
||||||
|
|
||||||
// target format yyyy-MM-ddThh:mm
|
|
||||||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
|
||||||
};
|
|
@@ -1,21 +0,0 @@
|
|||||||
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
|
|
||||||
|
|
||||||
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
|
||||||
const record = useRecordContext();
|
|
||||||
if (!record) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DeleteWithConfirmButton
|
|
||||||
{...props}
|
|
||||||
label="ra.action.remove"
|
|
||||||
confirmTitle="resources.devices.action.erase.title"
|
|
||||||
confirmContent="resources.devices.action.erase.content"
|
|
||||||
mutationMode="pessimistic"
|
|
||||||
redirect={false}
|
|
||||||
translateOptions={{
|
|
||||||
id: record.id,
|
|
||||||
name: record.display_name ? record.display_name : record.id,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
37
src/components/etke.cc/CurrentlyRunningCommand.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Stack, Tooltip, Typography, Box, Link } from "@mui/material";
|
||||||
|
import { useStore } from "react-admin";
|
||||||
|
|
||||||
|
import { ServerProcessResponse } from "../../synapse/dataProvider";
|
||||||
|
import { getTimeSince } from "../../utils/date";
|
||||||
|
|
||||||
|
const CurrentlyRunningCommand = () => {
|
||||||
|
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||||
|
command: "",
|
||||||
|
locked_at: "",
|
||||||
|
});
|
||||||
|
const { command, locked_at } = serverProcess;
|
||||||
|
|
||||||
|
if (!command || !locked_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={1} direction="row" alignItems="center">
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Typography variant="h5">Currently running:</Typography>
|
||||||
|
<Typography variant="h5" color="text.secondary">
|
||||||
|
<Link href={"https://etke.cc/help/extras/scheduler/#" + command} target="_blank">
|
||||||
|
{command}
|
||||||
|
</Link>
|
||||||
|
<Tooltip title={locked_at.toString()}>
|
||||||
|
<Typography component="span" color="text.secondary" sx={{ display: "inline-block", ml: 1 }}>
|
||||||
|
(started {getTimeSince(locked_at)} ago)
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CurrentlyRunningCommand;
|
67
src/components/etke.cc/README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# etke.cc-specific components
|
||||||
|
|
||||||
|
This directory contains [etke.cc](https://etke.cc)-specific components, unusable for any other purposes and/or configuration.
|
||||||
|
|
||||||
|
We at [etke.cc](https://etke.cc) attempting to develop everything open-source, but some things are too specific to be used by anyone else. This directory contains such components - they are only available for [etke.cc](https://etke.cc) customers.
|
||||||
|
|
||||||
|
Due to the specifics mentioned above, these components are documented here rather than in the [docs](../../../docs/README.md), plus they are not supported as part of the Synapse Admin open-source project (i.e.: no issues, no PRs, no support, no requests, etc.).
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Server Status icon
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
In the application bar the new monitoring icon is displayed that shows the current server status, and has the following color dot (and tooltip indicators):
|
||||||
|
|
||||||
|
* 🟢 (green) - the server is up and running, everything is fine, no issues detected
|
||||||
|
* 🟡 (yellow) - the server is up and running, but there is a command in progress (likely [maintenance](https://etke.cc/help/extras/scheduler/#maintenance)), so some temporary issues may occur - that's totally fine
|
||||||
|
* 🔴 (red) - there is at least 1 issue with one of the server's components
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The same icon (and link to the [Server Status page](#server-status-page)) is displayed in the sidebar.
|
||||||
|
|
||||||
|
### Server Status page
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
When you click on the [Server Status icon](#server-status-icon) in the application bar, you will be redirected to the
|
||||||
|
Server Status page. This page contains the following information:
|
||||||
|
|
||||||
|
* Overall server status (up/updating/has issues)
|
||||||
|
* Details about the currently running command (if any)
|
||||||
|
* Details about the server's components statuses (up/down with error details and suggested actions) by categories
|
||||||
|
|
||||||
|
This is [a Monitoring report](https://etke.cc/services/monitoring/)
|
||||||
|
|
||||||
|
### Server Notifications icon
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
In the application bar the new notifications icon is displayed that shows the number of unread (not removed) notifications
|
||||||
|
|
||||||
|
### Server Notifications page
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
When you click on a notification from the [Server Notifications icon](#server-notifications-icon)'s list in the application bar, you will be redirected to the Server Notifications page. This page contains the full text of all the notifications you have about your server.
|
||||||
|
|
||||||
|
### Server Actions Page
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
When you click on the `Server Actions` sidebar menu item, you will be redirected to the Server Actions page.
|
||||||
|
On this page you can do the following:
|
||||||
|
|
||||||
|
* [Run a command](#server-commands-panel) on your server immediately
|
||||||
|
* [Schedule a command](https://etke.cc/help/extras/scheduler/#schedule) to run at a specific date and time
|
||||||
|
* [Configure a recurring schedule](https://etke.cc/help/extras/scheduler/#recurring) for a command to run at a specific time every week
|
||||||
|
|
||||||
|
### Server Commands Panel
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
When you open [Server Actions page](#server-status-page), you will see the Server Commands panel.
|
||||||
|
This panel contains all [the commands](https://etke.cc/help/extras/scheduler/#commands) you can run on your server in 1 click.
|
||||||
|
Once command is finished, you will get a notification about the result.
|
51
src/components/etke.cc/ServerActionsPage.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import RestoreIcon from "@mui/icons-material/Restore";
|
||||||
|
import ScheduleIcon from "@mui/icons-material/Schedule";
|
||||||
|
import { Box, Typography, Link, Divider } from "@mui/material";
|
||||||
|
import { Stack } from "@mui/material";
|
||||||
|
|
||||||
|
import CurrentlyRunningCommand from "./CurrentlyRunningCommand";
|
||||||
|
import ServerCommandsPanel from "./ServerCommandsPanel";
|
||||||
|
import RecurringCommandsList from "./schedules/components/recurring/RecurringCommandsList";
|
||||||
|
import ScheduledCommandsList from "./schedules/components/scheduled/ScheduledCommandsList";
|
||||||
|
const ServerActionsPage = () => {
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} mt={3}>
|
||||||
|
<Stack direction="column">
|
||||||
|
<CurrentlyRunningCommand />
|
||||||
|
<ServerCommandsPanel />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h5">
|
||||||
|
<ScheduleIcon sx={{ verticalAlign: "middle", mr: 1 }} /> Scheduled commands
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
The following commands are scheduled to run at specific times. You can view their details and modify them as
|
||||||
|
needed. More details about the mode can be found{" "}
|
||||||
|
<Link href="https://etke.cc/help/extras/scheduler/#schedule" target="_blank">
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
<ScheduledCommandsList />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h5">
|
||||||
|
<RestoreIcon sx={{ verticalAlign: "middle", mr: 1 }} /> Recurring commands
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
The following commands are set to run at specific weekday and time (weekly). You can view their details and
|
||||||
|
modify them as needed. More details about the mode can be found{" "}
|
||||||
|
<Link href="https://etke.cc/help/extras/scheduler/#recurring" target="_blank">
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
<RecurringCommandsList />
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerActionsPage;
|
208
src/components/etke.cc/ServerCommandsPanel.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { PlayArrow, CheckCircle, HelpCenter, Construction } from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Link,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button, Loading, useDataProvider, useCreatePath, useStore } from "react-admin";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../Context";
|
||||||
|
import { useServerCommands } from "./hooks/useServerCommands";
|
||||||
|
import { ServerCommand, ServerProcessResponse } from "../../synapse/dataProvider";
|
||||||
|
import { Icons } from "../../utils/icons";
|
||||||
|
|
||||||
|
const renderIcon = (icon: string) => {
|
||||||
|
const IconComponent = Icons[icon] as React.ComponentType<any> | undefined;
|
||||||
|
return IconComponent ? <IconComponent sx={{ verticalAlign: "middle", mr: 1 }} /> : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerCommandsPanel = () => {
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
if (!etkeccAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPath = useCreatePath();
|
||||||
|
const { isLoading, serverCommands, setServerCommands } = useServerCommands();
|
||||||
|
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||||
|
command: "",
|
||||||
|
locked_at: "",
|
||||||
|
});
|
||||||
|
const [commandIsRunning, setCommandIsRunning] = useState<boolean>(serverProcess.command !== "");
|
||||||
|
const [commandResult, setCommandResult] = useState<React.ReactNode[]>([]);
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverProcess.command === "") {
|
||||||
|
setCommandIsRunning(false);
|
||||||
|
}
|
||||||
|
}, [serverProcess]);
|
||||||
|
|
||||||
|
const setCommandAdditionalArgs = (command: string, additionalArgs: string) => {
|
||||||
|
const updatedServerCommands = { ...serverCommands };
|
||||||
|
updatedServerCommands[command].additionalArgs = additionalArgs;
|
||||||
|
setServerCommands(updatedServerCommands);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCommand = async (command: string) => {
|
||||||
|
setCommandResult([]);
|
||||||
|
setCommandIsRunning(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const additionalArgs = serverCommands[command].additionalArgs || "";
|
||||||
|
const requestParams = additionalArgs ? { args: additionalArgs } : {};
|
||||||
|
|
||||||
|
const response = await dataProvider.runServerCommand(etkeccAdmin, command, requestParams);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
setCommandIsRunning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI with success message
|
||||||
|
const commandResults = buildCommandResultMessages(command, additionalArgs);
|
||||||
|
setCommandResult(commandResults);
|
||||||
|
|
||||||
|
// Reset the additional args field
|
||||||
|
resetCommandArgs(command);
|
||||||
|
|
||||||
|
// Update server process status
|
||||||
|
await updateServerProcessStatus(serverCommands[command]);
|
||||||
|
} catch (error) {
|
||||||
|
setCommandIsRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCommandResultMessages = (command: string, additionalArgs: string): React.ReactNode[] => {
|
||||||
|
const results: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
let commandScheduledText = `Command scheduled: ${command}`;
|
||||||
|
if (additionalArgs) {
|
||||||
|
commandScheduledText += `, with additional args: ${additionalArgs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(<Box key="command-text">{commandScheduledText}</Box>);
|
||||||
|
results.push(
|
||||||
|
<Box key="notification-link">
|
||||||
|
Expect your result in the{" "}
|
||||||
|
<RouterLink to={createPath({ resource: "server_notifications", type: "list" })}>Notifications</RouterLink> page
|
||||||
|
soon.
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCommandArgs = (command: string) => {
|
||||||
|
const updatedServerCommands = { ...serverCommands };
|
||||||
|
updatedServerCommands[command].additionalArgs = "";
|
||||||
|
setServerCommands(updatedServerCommands);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateServerProcessStatus = async (command: ServerCommand) => {
|
||||||
|
const commandIsLocking = command.with_lock;
|
||||||
|
const serverProcess = await dataProvider.getServerRunningProcess(etkeccAdmin, true);
|
||||||
|
if (!commandIsLocking && serverProcess.command === "") {
|
||||||
|
// if command is not locking, we simulate the "lock" mechanism so notifications will be refetched
|
||||||
|
serverProcess["command"] = command.name;
|
||||||
|
serverProcess["locked_at"] = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
setServerProcess({ ...serverProcess });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography variant="h5">
|
||||||
|
<Construction sx={{ verticalAlign: "middle", mr: 1 }} /> Available Commands
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ mt: 0 }}>
|
||||||
|
The following commands are available to run. More details about each of them can be found{" "}
|
||||||
|
<Link href="https://etke.cc/help/extras/scheduler/#commands" target="_blank">
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
<TableContainer component={Paper} sx={{ mt: 2 }}>
|
||||||
|
<Table sx={{ minWidth: 450 }} size="small" aria-label="simple table">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Command</TableCell>
|
||||||
|
<TableCell></TableCell>
|
||||||
|
<TableCell>Description</TableCell>
|
||||||
|
<TableCell></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{Object.entries(serverCommands).map(([command, { icon, args, description, additionalArgs }]) => (
|
||||||
|
<TableRow key={command}>
|
||||||
|
<TableCell scope="row">
|
||||||
|
<Box>
|
||||||
|
{renderIcon(icon)}
|
||||||
|
{command}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link href={"https://etke.cc/help/extras/scheduler/#" + command} target="_blank">
|
||||||
|
<Button size="small" startIcon={<HelpCenter />} title={command + " help"} />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{description}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{args && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
variant="standard"
|
||||||
|
onChange={e => {
|
||||||
|
setCommandAdditionalArgs(command, e.target.value);
|
||||||
|
}}
|
||||||
|
value={additionalArgs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
label="Run"
|
||||||
|
startIcon={<PlayArrow />}
|
||||||
|
onClick={() => {
|
||||||
|
runCommand(command);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
commandIsRunning || (args && typeof additionalArgs === "string" && additionalArgs.length === 0)
|
||||||
|
}
|
||||||
|
></Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{commandResult.length > 0 && (
|
||||||
|
<Alert icon={<CheckCircle fontSize="inherit" />} severity="success">
|
||||||
|
{commandResult.map((result, index) => (
|
||||||
|
<div key={index}>{result}</div>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerCommandsPanel;
|
245
src/components/etke.cc/ServerNotificationsBadge.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
useTheme,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Popper,
|
||||||
|
ClickAwayListener,
|
||||||
|
Box,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
ListSubheader,
|
||||||
|
IconButton,
|
||||||
|
Divider,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Fragment, useEffect, useState } from "react";
|
||||||
|
import { useDataProvider, useStore } from "react-admin";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../Context";
|
||||||
|
import { ServerNotificationsResponse, ServerProcessResponse } from "../../synapse/dataProvider";
|
||||||
|
import { getTimeSince } from "../../utils/date";
|
||||||
|
|
||||||
|
// 5 minutes
|
||||||
|
const SERVER_NOTIFICATIONS_INTERVAL_TIME = 300000;
|
||||||
|
|
||||||
|
const useServerNotifications = () => {
|
||||||
|
const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", {
|
||||||
|
notifications: [],
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||||
|
command: "",
|
||||||
|
locked_at: "",
|
||||||
|
});
|
||||||
|
const { command, locked_at } = serverProcess;
|
||||||
|
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const { notifications, success } = serverNotifications;
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(
|
||||||
|
etkeccAdmin,
|
||||||
|
command !== ""
|
||||||
|
);
|
||||||
|
const serverNotifications = [...notificationsResponse.notifications];
|
||||||
|
serverNotifications.reverse();
|
||||||
|
setServerNotifications({
|
||||||
|
...notificationsResponse,
|
||||||
|
notifications: serverNotifications,
|
||||||
|
success: notificationsResponse.success,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteServerNotifications = async () => {
|
||||||
|
const deleteResponse = await dataProvider.deleteServerNotifications(etkeccAdmin);
|
||||||
|
if (deleteResponse.success) {
|
||||||
|
setServerNotifications({
|
||||||
|
notifications: [],
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let serverNotificationsInterval: NodeJS.Timeout | null = null;
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
if (etkeccAdmin) {
|
||||||
|
fetchNotifications();
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
// start the interval after the SERVER_NOTIFICATIONS_INTERVAL_TIME to avoid too many requests
|
||||||
|
serverNotificationsInterval = setInterval(fetchNotifications, SERVER_NOTIFICATIONS_INTERVAL_TIME);
|
||||||
|
}, SERVER_NOTIFICATIONS_INTERVAL_TIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
if (serverNotificationsInterval) {
|
||||||
|
clearInterval(serverNotificationsInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [etkeccAdmin, command, locked_at]);
|
||||||
|
|
||||||
|
return { success, notifications, deleteServerNotifications };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServerNotificationsBadge = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { success, notifications, deleteServerNotifications } = useServerNotifications();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
// Modify menu state to work with Popper
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
|
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(anchorEl ? null : event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeeAllNotifications = () => {
|
||||||
|
handleClose();
|
||||||
|
navigate("/server_notifications");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAllNotifications = async () => {
|
||||||
|
deleteServerNotifications();
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<IconButton onClick={handleOpen} sx={{ color: theme.palette.common.white }}>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
notifications && notifications.length > 0
|
||||||
|
? `${notifications.length} new notifications`
|
||||||
|
: `No notifications yet`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(notifications && notifications.length > 0 && (
|
||||||
|
<Badge badgeContent={notifications.length} color="error">
|
||||||
|
<NotificationsIcon />
|
||||||
|
</Badge>
|
||||||
|
)) || <NotificationsIcon />}
|
||||||
|
</Tooltip>
|
||||||
|
</IconButton>
|
||||||
|
<Popper open={open} anchorEl={anchorEl} placement="bottom-end" style={{ zIndex: 1300 }}>
|
||||||
|
<ClickAwayListener onClickAway={handleClose}>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
maxHeight: "350px",
|
||||||
|
paddingTop: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
minWidth: "300px",
|
||||||
|
maxWidth: {
|
||||||
|
xs: "100vw", // Full width on mobile
|
||||||
|
sm: "400px", // Fixed width on desktop
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!notifications || notifications.length === 0 ? (
|
||||||
|
<Typography sx={{ p: 1 }} variant="body2">
|
||||||
|
No new notifications
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List sx={{ p: 0 }} dense={true}>
|
||||||
|
<ListSubheader
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Notifications</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{ cursor: "pointer", color: theme.palette.primary.main }}
|
||||||
|
onClick={() => handleSeeAllNotifications()}
|
||||||
|
>
|
||||||
|
See all notifications
|
||||||
|
</Box>
|
||||||
|
</ListSubheader>
|
||||||
|
<Divider />
|
||||||
|
{notifications.map((notification, index) => {
|
||||||
|
return (
|
||||||
|
<Fragment key={notification.event_id ? notification.event_id + index : index}>
|
||||||
|
<ListItem
|
||||||
|
onClick={() => handleSeeAllNotifications()}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
overflow: "hidden",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "action.hover",
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: notification.output.split("\n")[0] }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography variant="body2" sx={{ color: theme.palette.text.secondary }}>
|
||||||
|
{getTimeSince(notification.sent_at) + " ago"}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<Divider />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ListItem>
|
||||||
|
<Button
|
||||||
|
key="clear-all-notifications"
|
||||||
|
onClick={() => handleClearAllNotifications()}
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
sx={{
|
||||||
|
pl: 0,
|
||||||
|
pt: 1,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" sx={{ mr: 1 }} />
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</ClickAwayListener>
|
||||||
|
</Popper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
66
src/components/etke.cc/ServerNotificationsPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import { Box, Typography, Paper, Button } from "@mui/material";
|
||||||
|
import { Stack } from "@mui/material";
|
||||||
|
import { Tooltip } from "@mui/material";
|
||||||
|
import { useStore } from "react-admin";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../Context";
|
||||||
|
import dataProvider, { ServerNotificationsResponse } from "../../synapse/dataProvider";
|
||||||
|
import { getTimeSince } from "../../utils/date";
|
||||||
|
|
||||||
|
const DisplayTime = ({ date }: { date: string }) => {
|
||||||
|
const dateFromDateString = new Date(date.replace(" ", "T") + "Z");
|
||||||
|
return <Tooltip title={dateFromDateString.toLocaleString()}>{<span>{getTimeSince(date) + " ago"}</span>}</Tooltip>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerNotificationsPage = () => {
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", {
|
||||||
|
notifications: [],
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifications = serverNotifications.notifications;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} mt={3}>
|
||||||
|
<Stack spacing={1} direction="row" alignItems="center">
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", gap: 1 }}>
|
||||||
|
<Typography variant="h4">Server Notifications</Typography>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
onClick={async () => {
|
||||||
|
await dataProvider.deleteServerNotifications(etkeccAdmin);
|
||||||
|
setServerNotifications({
|
||||||
|
notifications: [],
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Clear
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
<Typography>No new notifications.</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
notifications.map((notification, index) => (
|
||||||
|
<Paper key={notification.event_id ? notification.event_id : index} sx={{ p: 2 }}>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold" color="text.secondary">
|
||||||
|
<DisplayTime date={notification.sent_at} />
|
||||||
|
</Typography>
|
||||||
|
<Typography dangerouslySetInnerHTML={{ __html: notification.output }} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerNotificationsPage;
|
241
src/components/etke.cc/ServerStatusBadge.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import MonitorHeartIcon from "@mui/icons-material/MonitorHeart";
|
||||||
|
import { Avatar, Badge, Box, Theme, Tooltip } from "@mui/material";
|
||||||
|
import { BadgeProps } from "@mui/material/Badge";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button, useDataProvider, useStore } from "react-admin";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../Context";
|
||||||
|
import { ServerProcessResponse, ServerStatusResponse } from "../../synapse/dataProvider";
|
||||||
|
|
||||||
|
interface StyledBadgeProps extends BadgeProps {
|
||||||
|
backgroundColor: string;
|
||||||
|
badgeColor: string;
|
||||||
|
theme?: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledBadge = styled(Badge, {
|
||||||
|
shouldForwardProp: prop => !["badgeColor", "backgroundColor"].includes(prop as string),
|
||||||
|
})<StyledBadgeProps>(({ theme, backgroundColor, badgeColor }) => ({
|
||||||
|
"& .MuiBadge-badge": {
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
color: badgeColor,
|
||||||
|
boxShadow: `0 0 0 2px ${theme.palette.background.paper}`,
|
||||||
|
"&::after": {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "ripple 2.5s infinite ease-in-out",
|
||||||
|
border: "1px solid currentColor",
|
||||||
|
content: '""',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"@keyframes ripple": {
|
||||||
|
"0%": {
|
||||||
|
transform: "scale(.8)",
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
"100%": {
|
||||||
|
transform: "scale(2.4)",
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// every 5 minutes
|
||||||
|
const SERVER_STATUS_INTERVAL_TIME = 5 * 60 * 1000;
|
||||||
|
// every 5 seconds
|
||||||
|
const SERVER_CURRENT_PROCCESS_INTERVAL_TIME = 5 * 1000;
|
||||||
|
|
||||||
|
const useServerStatus = () => {
|
||||||
|
const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", {
|
||||||
|
ok: false,
|
||||||
|
success: false,
|
||||||
|
host: "",
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||||
|
command: "",
|
||||||
|
locked_at: "",
|
||||||
|
});
|
||||||
|
const { command, locked_at } = serverProcess;
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const isOkay = serverStatus.ok;
|
||||||
|
const successCheck = serverStatus.success;
|
||||||
|
|
||||||
|
const checkServerStatus = async () => {
|
||||||
|
const serverStatus: ServerStatusResponse = await dataProvider.getServerStatus(etkeccAdmin, command !== "");
|
||||||
|
setServerStatus({
|
||||||
|
ok: serverStatus.ok,
|
||||||
|
success: serverStatus.success,
|
||||||
|
host: serverStatus.host,
|
||||||
|
results: serverStatus.results,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let serverStatusInterval: NodeJS.Timeout | null = null;
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
if (etkeccAdmin) {
|
||||||
|
checkServerStatus();
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
// start the interval after 10 seconds to avoid too many requests
|
||||||
|
serverStatusInterval = setInterval(checkServerStatus, SERVER_STATUS_INTERVAL_TIME);
|
||||||
|
}, 10000);
|
||||||
|
} else {
|
||||||
|
setServerStatus({ ok: false, success: false, host: "", results: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
if (serverStatusInterval) {
|
||||||
|
clearInterval(serverStatusInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [etkeccAdmin, command]);
|
||||||
|
|
||||||
|
return { isOkay, successCheck };
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCurrentServerProcess = () => {
|
||||||
|
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||||
|
command: "",
|
||||||
|
locked_at: "",
|
||||||
|
});
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const { command, locked_at } = serverProcess;
|
||||||
|
|
||||||
|
const checkServerRunningProcess = async () => {
|
||||||
|
const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess(
|
||||||
|
etkeccAdmin,
|
||||||
|
command !== ""
|
||||||
|
);
|
||||||
|
setServerProcess({
|
||||||
|
...serverProcess,
|
||||||
|
command: serverProcess.command,
|
||||||
|
locked_at: serverProcess.locked_at,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let serverCheckInterval: NodeJS.Timeout | null = null;
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
if (etkeccAdmin) {
|
||||||
|
checkServerRunningProcess();
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
serverCheckInterval = setInterval(checkServerRunningProcess, SERVER_CURRENT_PROCCESS_INTERVAL_TIME);
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
setServerProcess({ command: "", locked_at: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
if (serverCheckInterval) {
|
||||||
|
clearInterval(serverCheckInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [etkeccAdmin, command]);
|
||||||
|
|
||||||
|
return { command, locked_at };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServerStatusStyledBadge = ({
|
||||||
|
command,
|
||||||
|
locked_at,
|
||||||
|
isOkay,
|
||||||
|
isLoaded,
|
||||||
|
inSidebar = false,
|
||||||
|
}: {
|
||||||
|
command: string;
|
||||||
|
locked_at: string;
|
||||||
|
isOkay: boolean;
|
||||||
|
isLoaded: boolean;
|
||||||
|
inSidebar: boolean;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
let badgeBackgroundColor = isLoaded
|
||||||
|
? isOkay
|
||||||
|
? theme.palette.success.light
|
||||||
|
: theme.palette.error.main
|
||||||
|
: theme.palette.grey[600];
|
||||||
|
let badgeColor = isLoaded
|
||||||
|
? isOkay
|
||||||
|
? theme.palette.success.light
|
||||||
|
: theme.palette.error.main
|
||||||
|
: theme.palette.grey[600];
|
||||||
|
|
||||||
|
if (command && locked_at) {
|
||||||
|
badgeBackgroundColor = theme.palette.warning.main;
|
||||||
|
badgeColor = theme.palette.warning.main;
|
||||||
|
}
|
||||||
|
let avatarBackgroundColor = theme.palette.mode === "dark" ? theme.palette.background.default : "#2196f3";
|
||||||
|
if (inSidebar) {
|
||||||
|
avatarBackgroundColor = theme.palette.grey[600];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledBadge
|
||||||
|
overlap="circular"
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
|
variant="dot"
|
||||||
|
backgroundColor={badgeBackgroundColor}
|
||||||
|
badgeColor={badgeColor}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ height: 24, width: 24, background: avatarBackgroundColor }}>
|
||||||
|
<MonitorHeartIcon sx={{ height: 22, width: 22, color: theme.palette.common.white }} />
|
||||||
|
</Avatar>
|
||||||
|
</StyledBadge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerStatusBadge = () => {
|
||||||
|
const { isOkay, successCheck } = useServerStatus();
|
||||||
|
const { command, locked_at } = useCurrentServerProcess();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!successCheck) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleServerStatusClick = () => {
|
||||||
|
navigate("/server_status");
|
||||||
|
};
|
||||||
|
|
||||||
|
let tooltipText = "Click to view Server Status";
|
||||||
|
|
||||||
|
if (command && locked_at) {
|
||||||
|
tooltipText = `Running: ${command}; ${tooltipText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={handleServerStatusClick} size="medium" sx={{ minWidth: "auto", ".MuiButton-startIcon": { m: 0 } }}>
|
||||||
|
<Tooltip title={tooltipText} sx={{ cursor: "pointer" }}>
|
||||||
|
<Box>
|
||||||
|
<ServerStatusStyledBadge
|
||||||
|
inSidebar={false}
|
||||||
|
command={command || ""}
|
||||||
|
locked_at={locked_at || ""}
|
||||||
|
isOkay={isOkay}
|
||||||
|
isLoaded={successCheck}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerStatusBadge;
|
147
src/components/etke.cc/ServerStatusPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import EngineeringIcon from "@mui/icons-material/Engineering";
|
||||||
|
import { Alert, Box, Stack, Typography, Paper, Link, Chip, Divider, Tooltip, ChipProps } from "@mui/material";
|
||||||
|
import { useStore } from "ra-core";
|
||||||
|
|
||||||
|
import CurrentlyRunningCommand from "./CurrentlyRunningCommand";
|
||||||
|
import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../synapse/dataProvider";
|
||||||
|
import { getTimeSince } from "../../utils/date";
|
||||||
|
|
||||||
|
const StatusChip = ({
|
||||||
|
isOkay,
|
||||||
|
size = "medium",
|
||||||
|
command,
|
||||||
|
}: {
|
||||||
|
isOkay: boolean;
|
||||||
|
size?: "small" | "medium";
|
||||||
|
command?: string;
|
||||||
|
}) => {
|
||||||
|
let label = "OK";
|
||||||
|
let icon = <CheckIcon />;
|
||||||
|
let color: ChipProps["color"] = "success";
|
||||||
|
if (!isOkay) {
|
||||||
|
label = "Error";
|
||||||
|
icon = <CloseIcon />;
|
||||||
|
color = "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command) {
|
||||||
|
label = command;
|
||||||
|
color = "warning";
|
||||||
|
icon = <EngineeringIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Chip icon={icon} label={label} color={color} variant="outlined" size={size} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerComponentText = ({ text }: { text: string }) => {
|
||||||
|
return <Typography variant="body1" dangerouslySetInnerHTML={{ __html: text }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerStatusPage = () => {
|
||||||
|
const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", {
|
||||||
|
ok: false,
|
||||||
|
success: false,
|
||||||
|
host: "",
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||||
|
command: "",
|
||||||
|
locked_at: "",
|
||||||
|
});
|
||||||
|
const { command, locked_at } = serverProcess;
|
||||||
|
const successCheck = serverStatus.success;
|
||||||
|
const isOkay = serverStatus.ok;
|
||||||
|
const host = serverStatus.host;
|
||||||
|
const results = serverStatus.results;
|
||||||
|
|
||||||
|
const groupedResults: Record<string, ServerStatusComponent[]> = {};
|
||||||
|
for (const result of results) {
|
||||||
|
if (!groupedResults[result.category]) {
|
||||||
|
groupedResults[result.category] = [];
|
||||||
|
}
|
||||||
|
groupedResults[result.category].push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!successCheck) {
|
||||||
|
return (
|
||||||
|
<Paper elevation={3} sx={{ p: 3, mt: 3 }}>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Typography color="info">Fetching real-time server health... Just a moment!</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} mt={3}>
|
||||||
|
<Stack spacing={1} direction="row" alignItems="center">
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Typography variant="h4">Status:</Typography>
|
||||||
|
<StatusChip isOkay={isOkay} command={command} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" color="primary" fontWeight="medium">
|
||||||
|
{host}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<CurrentlyRunningCommand />
|
||||||
|
|
||||||
|
<Typography variant="body1">
|
||||||
|
This is a{" "}
|
||||||
|
<Link href="https://etke.cc/services/monitoring/" target="_blank">
|
||||||
|
monitoring report
|
||||||
|
</Link>{" "}
|
||||||
|
of the server. If any of the checks below concern you, please check the{" "}
|
||||||
|
<Link
|
||||||
|
href="https://etke.cc/services/monitoring/#what-to-do-if-the-monitoring-report-shows-issues"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
suggested actions
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2} direction="row">
|
||||||
|
{Object.keys(groupedResults).map((category, idx) => (
|
||||||
|
<Box key={`category_${category}`} sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="h5" mb={1}>
|
||||||
|
{category}
|
||||||
|
</Typography>
|
||||||
|
<Paper elevation={2} sx={{ p: 3 }}>
|
||||||
|
<Stack spacing={1} divider={<Divider />}>
|
||||||
|
{groupedResults[category].map((result, idx) => (
|
||||||
|
<Box key={`${category}_${idx}`}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<StatusChip isOkay={result.ok} size="small" />
|
||||||
|
{result.label.url ? (
|
||||||
|
<Link href={result.label.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ServerComponentText text={result.label.text} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<ServerComponentText text={result.label.text} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{result.reason && (
|
||||||
|
<Typography color="text.secondary" dangerouslySetInnerHTML={{ __html: result.reason }} />
|
||||||
|
)}
|
||||||
|
{!result.ok && result.help && (
|
||||||
|
<Link href={result.help} target="_blank" rel="noopener noreferrer" sx={{ mt: 1 }}>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerStatusPage;
|
29
src/components/etke.cc/hooks/useServerCommands.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useDataProvider } from "react-admin";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../../Context";
|
||||||
|
import { ServerCommand } from "../../../synapse/dataProvider";
|
||||||
|
|
||||||
|
export const useServerCommands = () => {
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [serverCommands, setServerCommands] = useState<Record<string, ServerCommand>>({});
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchServerCommands = async () => {
|
||||||
|
const serverCommandsResponse = await dataProvider.getServerCommands(etkeccAdmin);
|
||||||
|
if (serverCommandsResponse) {
|
||||||
|
const serverCommands = serverCommandsResponse;
|
||||||
|
Object.keys(serverCommandsResponse).forEach((command: string) => {
|
||||||
|
serverCommands[command].additionalArgs = "";
|
||||||
|
});
|
||||||
|
setServerCommands(serverCommands);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
fetchServerCommands();
|
||||||
|
}, [dataProvider, etkeccAdmin]);
|
||||||
|
|
||||||
|
return { isLoading, serverCommands, setServerCommands };
|
||||||
|
};
|
@@ -0,0 +1,21 @@
|
|||||||
|
const transformCommandsToChoices = (commands: Record<string, any>) => {
|
||||||
|
return Object.entries(commands).map(([key, value]) => ({
|
||||||
|
id: key,
|
||||||
|
name: value.name,
|
||||||
|
description: value.description,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScheduledCommandCreate = () => {
|
||||||
|
const commandChoices = transformCommandsToChoices(serverCommands);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleForm>
|
||||||
|
<SelectInput
|
||||||
|
source="command"
|
||||||
|
choices={commandChoices}
|
||||||
|
optionText={choice => `${choice.name} - ${choice.description}`}
|
||||||
|
/>
|
||||||
|
</SimpleForm>
|
||||||
|
);
|
||||||
|
};
|
@@ -0,0 +1,182 @@
|
|||||||
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
|
import { Card, CardContent, CardHeader, Box, Alert, Typography, Link } from "@mui/material";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
TextInput,
|
||||||
|
SaveButton,
|
||||||
|
useNotify,
|
||||||
|
useDataProvider,
|
||||||
|
Loading,
|
||||||
|
Button,
|
||||||
|
SelectInput,
|
||||||
|
TimeInput,
|
||||||
|
} from "react-admin";
|
||||||
|
import { useWatch } from "react-hook-form";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import RecurringDeleteButton from "./RecurringDeleteButton";
|
||||||
|
import { useAppContext } from "../../../../../Context";
|
||||||
|
import { RecurringCommand } from "../../../../../synapse/dataProvider";
|
||||||
|
import { useServerCommands } from "../../../hooks/useServerCommands";
|
||||||
|
import { useRecurringCommands } from "../../hooks/useRecurringCommands";
|
||||||
|
|
||||||
|
const transformCommandsToChoices = (commands: Record<string, any>) => {
|
||||||
|
return Object.entries(commands).map(([key, value]) => ({
|
||||||
|
id: key,
|
||||||
|
name: value.name,
|
||||||
|
description: value.description,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const ArgumentsField = ({ serverCommands }) => {
|
||||||
|
const selectedCommand = useWatch({ name: "command" });
|
||||||
|
const showArgs = selectedCommand && serverCommands[selectedCommand]?.args === true;
|
||||||
|
|
||||||
|
if (!showArgs) return null;
|
||||||
|
|
||||||
|
return <TextInput required source="args" label="Arguments" fullWidth multiline />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecurringCommandEdit = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const notify = useNotify();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const [command, setCommand] = useState<RecurringCommand | undefined>(undefined);
|
||||||
|
const isCreating = typeof id === "undefined";
|
||||||
|
const [loading, setLoading] = useState(!isCreating);
|
||||||
|
const { data: recurringCommands, isLoading: isLoadingList } = useRecurringCommands();
|
||||||
|
const { serverCommands, isLoading: isLoadingServerCommands } = useServerCommands();
|
||||||
|
const pageTitle = isCreating ? "Create Recurring Command" : "Edit Recurring Command";
|
||||||
|
|
||||||
|
const commandChoices = transformCommandsToChoices(serverCommands);
|
||||||
|
const dayOfWeekChoices = [
|
||||||
|
{ id: "Monday", name: "Monday" },
|
||||||
|
{ id: "Tuesday", name: "Tuesday" },
|
||||||
|
{ id: "Wednesday", name: "Wednesday" },
|
||||||
|
{ id: "Thursday", name: "Thursday" },
|
||||||
|
{ id: "Friday", name: "Friday" },
|
||||||
|
{ id: "Saturday", name: "Saturday" },
|
||||||
|
{ id: "Sunday", name: "Sunday" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreating && recurringCommands) {
|
||||||
|
const commandToEdit = recurringCommands.find(cmd => cmd.id === id);
|
||||||
|
if (commandToEdit) {
|
||||||
|
const timeValue = commandToEdit.time || "";
|
||||||
|
const timeParts = timeValue.split(" ");
|
||||||
|
|
||||||
|
const parsedCommand = {
|
||||||
|
...commandToEdit,
|
||||||
|
day_of_week: timeParts.length > 1 ? timeParts[0] : "Monday",
|
||||||
|
execution_time: timeParts.length > 1 ? timeParts[1] : timeValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
setCommand(parsedCommand);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id, recurringCommands, isCreating]);
|
||||||
|
|
||||||
|
const handleSubmit = async data => {
|
||||||
|
try {
|
||||||
|
// Format the time from the Date object to a string in HH:MM format
|
||||||
|
let formattedTime = "00:00";
|
||||||
|
|
||||||
|
if (data.execution_time instanceof Date) {
|
||||||
|
const hours = String(data.execution_time.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(data.execution_time.getMinutes()).padStart(2, "0");
|
||||||
|
formattedTime = `${hours}:${minutes}`;
|
||||||
|
} else if (typeof data.execution_time === "string") {
|
||||||
|
formattedTime = data.execution_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionData = {
|
||||||
|
...data,
|
||||||
|
time: `${data.day_of_week} ${formattedTime}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
delete submissionData.day_of_week;
|
||||||
|
delete submissionData.execution_time;
|
||||||
|
delete submissionData.scheduled_at;
|
||||||
|
|
||||||
|
// Only include args when it's required for the selected command
|
||||||
|
const selectedCommand = data.command;
|
||||||
|
if (!selectedCommand || !serverCommands[selectedCommand]?.args) {
|
||||||
|
delete submissionData.args;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (isCreating) {
|
||||||
|
result = await dataProvider.createRecurringCommand(etkeccAdmin, submissionData);
|
||||||
|
notify("recurring_commands.action.create_success", { type: "success" });
|
||||||
|
} else {
|
||||||
|
result = await dataProvider.updateRecurringCommand(etkeccAdmin, {
|
||||||
|
...submissionData,
|
||||||
|
id: id,
|
||||||
|
});
|
||||||
|
notify("recurring_commands.action.update_success", { type: "success" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate scheduled commands queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["scheduledCommands"] });
|
||||||
|
|
||||||
|
navigate("/server_actions");
|
||||||
|
} catch (error) {
|
||||||
|
notify("recurring_commands.action.update_failure", { type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || isLoadingList || isLoadingServerCommands) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Button label="Back" onClick={() => navigate("/server_actions")} startIcon={<ArrowBackIcon />} sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader title={pageTitle} />
|
||||||
|
<CardContent>
|
||||||
|
{command && (
|
||||||
|
<Alert severity="info">
|
||||||
|
<Typography variant="body1" sx={{ px: 2 }}>
|
||||||
|
You can find more details about the command{" "}
|
||||||
|
<Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank">
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Form
|
||||||
|
defaultValues={command || undefined}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
record={command || undefined}
|
||||||
|
warnWhenUnsavedChanges
|
||||||
|
>
|
||||||
|
<Box display="flex" flexDirection="column" gap={2}>
|
||||||
|
{!isCreating && <TextInput readOnly source="id" label="ID" fullWidth required />}
|
||||||
|
<SelectInput source="command" choices={commandChoices} label="Command" fullWidth required />
|
||||||
|
<ArgumentsField serverCommands={serverCommands} />
|
||||||
|
<SelectInput source="day_of_week" choices={dayOfWeekChoices} label="Day of Week" fullWidth required />
|
||||||
|
<TimeInput source="execution_time" label="Time (UTC)" fullWidth required />
|
||||||
|
<Box mt={2} display="flex" justifyContent="space-between">
|
||||||
|
<SaveButton label={isCreating ? "Create" : "Update"} />
|
||||||
|
{!isCreating && <RecurringDeleteButton />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecurringCommandEdit;
|
@@ -0,0 +1,62 @@
|
|||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import { Paper } from "@mui/material";
|
||||||
|
import { Loading, Button } from "react-admin";
|
||||||
|
import { DateField } from "react-admin";
|
||||||
|
import { Datagrid } from "react-admin";
|
||||||
|
import { ListContextProvider, TextField, TopToolbar, Identifier } from "react-admin";
|
||||||
|
import { ResourceContextProvider, useList } from "react-admin";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { DATE_FORMAT } from "../../../../../utils/date";
|
||||||
|
import { useRecurringCommands } from "../../hooks/useRecurringCommands";
|
||||||
|
|
||||||
|
const ListActions = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TopToolbar>
|
||||||
|
<Button label="Create" onClick={() => navigate("/server_actions/recurring/create")} startIcon={<AddIcon />} />
|
||||||
|
</TopToolbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecurringCommandsList = () => {
|
||||||
|
const { data, isLoading, error } = useRecurringCommands();
|
||||||
|
|
||||||
|
const listContext = useList({
|
||||||
|
resource: "recurring",
|
||||||
|
sort: { field: "scheduled_at", order: "DESC" },
|
||||||
|
perPage: 50,
|
||||||
|
data: data || [],
|
||||||
|
isLoading: isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourceContextProvider value="recurring">
|
||||||
|
<ListContextProvider value={listContext}>
|
||||||
|
<ListActions />
|
||||||
|
<Paper>
|
||||||
|
<Datagrid
|
||||||
|
bulkActionButtons={false}
|
||||||
|
rowClick={(id: Identifier, resource: string, record: any) => {
|
||||||
|
if (!record) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/server_actions/${resource}/${id}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField source="command" />
|
||||||
|
<TextField source="args" label="Arguments" />
|
||||||
|
<TextField source="time" label="Time (UTC)" />
|
||||||
|
<DateField options={DATE_FORMAT} showTime source="scheduled_at" label="Next run at (local time)" />
|
||||||
|
</Datagrid>
|
||||||
|
</Paper>
|
||||||
|
</ListContextProvider>
|
||||||
|
</ResourceContextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecurringCommandsList;
|
@@ -0,0 +1,65 @@
|
|||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNotify, useDataProvider, useRecordContext } from "react-admin";
|
||||||
|
import { Button, Confirm } from "react-admin";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../../../../Context";
|
||||||
|
import { RecurringCommand } from "../../../../../synapse/dataProvider";
|
||||||
|
|
||||||
|
const RecurringDeleteButton = () => {
|
||||||
|
const record = useRecordContext() as RecurringCommand;
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const notify = useNotify();
|
||||||
|
const theme = useTheme();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await dataProvider.deleteRecurringCommand(etkeccAdmin, record.id);
|
||||||
|
notify("recurring_commands.action.delete_success", { type: "success" });
|
||||||
|
navigate("/server_actions");
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
|
notify(`Error: ${errorMessage}`, { type: "error" });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
sx={{ color: theme.palette.error.main }}
|
||||||
|
label="Delete"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isDeleting}
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
/>
|
||||||
|
<Confirm
|
||||||
|
isOpen={open}
|
||||||
|
title="Delete Recurring Command"
|
||||||
|
content={`Are you sure you want to delete the command: ${record?.command || ""}?`}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onClose={handleCancel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecurringDeleteButton;
|
@@ -0,0 +1,141 @@
|
|||||||
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
|
import { Card, CardContent, CardHeader, Box } from "@mui/material";
|
||||||
|
import { Typography, Link } from "@mui/material";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
TextInput,
|
||||||
|
DateTimeInput,
|
||||||
|
SaveButton,
|
||||||
|
useNotify,
|
||||||
|
useDataProvider,
|
||||||
|
Loading,
|
||||||
|
Button,
|
||||||
|
BooleanInput,
|
||||||
|
SelectInput,
|
||||||
|
} from "react-admin";
|
||||||
|
import { useWatch } from "react-hook-form";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import ScheduleDeleteButton from "./ScheduledDeleteButton";
|
||||||
|
import { useAppContext } from "../../../../../Context";
|
||||||
|
import { ScheduledCommand } from "../../../../../synapse/dataProvider";
|
||||||
|
import { useServerCommands } from "../../../hooks/useServerCommands";
|
||||||
|
import { useScheduledCommands } from "../../hooks/useScheduledCommands";
|
||||||
|
|
||||||
|
const transformCommandsToChoices = (commands: Record<string, any>) => {
|
||||||
|
return Object.entries(commands).map(([key, value]) => ({
|
||||||
|
id: key,
|
||||||
|
name: value.name,
|
||||||
|
description: value.description,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const ArgumentsField = ({ serverCommands }) => {
|
||||||
|
const selectedCommand = useWatch({ name: "command" });
|
||||||
|
const showArgs = selectedCommand && serverCommands[selectedCommand]?.args === true;
|
||||||
|
|
||||||
|
if (!showArgs) return null;
|
||||||
|
|
||||||
|
return <TextInput required source="args" label="Arguments" fullWidth multiline />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScheduledCommandEdit = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const notify = useNotify();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const [command, setCommand] = useState<ScheduledCommand | null>(null);
|
||||||
|
const isCreating = typeof id === "undefined";
|
||||||
|
const [loading, setLoading] = useState(!isCreating);
|
||||||
|
const { data: scheduledCommands, isLoading: isLoadingList } = useScheduledCommands();
|
||||||
|
const { serverCommands, isLoading: isLoadingServerCommands } = useServerCommands();
|
||||||
|
const pageTitle = isCreating ? "Create Scheduled Command" : "Edit Scheduled Command";
|
||||||
|
|
||||||
|
const commandChoices = transformCommandsToChoices(serverCommands);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreating && scheduledCommands) {
|
||||||
|
const commandToEdit = scheduledCommands.find(cmd => cmd.id === id);
|
||||||
|
if (commandToEdit) {
|
||||||
|
setCommand(commandToEdit);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id, scheduledCommands, isCreating]);
|
||||||
|
|
||||||
|
const handleSubmit = async data => {
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
data.scheduled_at = new Date(data.scheduled_at).toISOString();
|
||||||
|
|
||||||
|
if (isCreating) {
|
||||||
|
result = await dataProvider.createScheduledCommand(etkeccAdmin, data);
|
||||||
|
notify("scheduled_commands.action.create_success", { type: "success" });
|
||||||
|
} else {
|
||||||
|
result = await dataProvider.updateScheduledCommand(etkeccAdmin, {
|
||||||
|
...data,
|
||||||
|
id: id,
|
||||||
|
});
|
||||||
|
notify("scheduled_commands.action.update_success", { type: "success" });
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate("/server_actions");
|
||||||
|
} catch (error) {
|
||||||
|
notify("scheduled_commands.action.update_failure", { type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || isLoadingList) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Button label="Back" onClick={() => navigate("/server_actions")} startIcon={<ArrowBackIcon />} sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader title={pageTitle} />
|
||||||
|
{command && (
|
||||||
|
<Typography variant="body1" sx={{ px: 2 }}>
|
||||||
|
You can find more details about the command{" "}
|
||||||
|
<Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank">
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<CardContent>
|
||||||
|
<Form
|
||||||
|
defaultValues={command || undefined}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
record={command || undefined}
|
||||||
|
warnWhenUnsavedChanges
|
||||||
|
>
|
||||||
|
<Box display="flex" flexDirection="column" gap={2}>
|
||||||
|
{command && <TextInput readOnly source="id" label="ID" fullWidth required />}
|
||||||
|
<SelectInput
|
||||||
|
readOnly={!isCreating}
|
||||||
|
source="command"
|
||||||
|
choices={commandChoices}
|
||||||
|
label="Command"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<ArgumentsField serverCommands={serverCommands} />
|
||||||
|
<DateTimeInput source="scheduled_at" label="Scheduled at" fullWidth required />
|
||||||
|
<Box mt={2} display="flex" justifyContent="space-between">
|
||||||
|
<SaveButton label={isCreating ? "Create" : "Update"} />
|
||||||
|
{!isCreating && <ScheduleDeleteButton />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScheduledCommandEdit;
|
@@ -0,0 +1,89 @@
|
|||||||
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
|
import { Alert, Box, Card, CardContent, CardHeader, Typography, Link } from "@mui/material";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Loading,
|
||||||
|
Button,
|
||||||
|
useDataProvider,
|
||||||
|
useNotify,
|
||||||
|
SimpleShowLayout,
|
||||||
|
TextField,
|
||||||
|
BooleanField,
|
||||||
|
DateField,
|
||||||
|
RecordContextProvider,
|
||||||
|
} from "react-admin";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import ScheduledDeleteButton from "./ScheduledDeleteButton";
|
||||||
|
import { useAppContext } from "../../../../../Context";
|
||||||
|
import { ScheduledCommand } from "../../../../../synapse/dataProvider";
|
||||||
|
import { useScheduledCommands } from "../../hooks/useScheduledCommands";
|
||||||
|
|
||||||
|
const ScheduledCommandShow = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [command, setCommand] = useState<ScheduledCommand | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { data: scheduledCommands, isLoading: isLoadingList } = useScheduledCommands();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scheduledCommands) {
|
||||||
|
const commandToShow = scheduledCommands.find(cmd => cmd.id === id);
|
||||||
|
if (commandToShow) {
|
||||||
|
setCommand(commandToShow);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id, scheduledCommands]);
|
||||||
|
|
||||||
|
if (loading || isLoadingList) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Button label="Back" onClick={() => navigate("/server_actions")} startIcon={<ArrowBackIcon />} sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<RecordContextProvider value={command}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Scheduled Command Details" />
|
||||||
|
<CardContent>
|
||||||
|
{command && (
|
||||||
|
<Alert severity="info">
|
||||||
|
<Typography variant="body1" sx={{ px: 2 }}>
|
||||||
|
You can find more details about the command{" "}
|
||||||
|
<Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank">
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<SimpleShowLayout>
|
||||||
|
<TextField source="id" label="ID" />
|
||||||
|
<TextField source="command" label="Command" />
|
||||||
|
{command.args && <TextField source="args" label="Arguments" />}
|
||||||
|
<BooleanField source="is_recurring" label="Is recurring" />
|
||||||
|
<DateField source="scheduled_at" label="Scheduled at" showTime />
|
||||||
|
</SimpleShowLayout>
|
||||||
|
{command.is_recurring && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
Scheduled commands created from a recurring one are not editable as they will be regenerated
|
||||||
|
automatically. Please edit the recurring command instead.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Box display="flex" justifyContent="flex-end" mt={2}>
|
||||||
|
<ScheduledDeleteButton />
|
||||||
|
</Box>
|
||||||
|
</RecordContextProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScheduledCommandShow;
|
@@ -0,0 +1,72 @@
|
|||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import { Paper } from "@mui/material";
|
||||||
|
import { Loading, Button, useNotify, useRefresh, useCreatePath, useRecordContext } from "react-admin";
|
||||||
|
import { ResourceContextProvider, useList } from "react-admin";
|
||||||
|
import { ListContextProvider, TextField } from "react-admin";
|
||||||
|
import { Datagrid } from "react-admin";
|
||||||
|
import { BooleanField, DateField, TopToolbar } from "react-admin";
|
||||||
|
import { useDataProvider } from "react-admin";
|
||||||
|
import { Identifier } from "react-admin";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../../../../Context";
|
||||||
|
import { DATE_FORMAT } from "../../../../../utils/date";
|
||||||
|
import { useScheduledCommands } from "../../hooks/useScheduledCommands";
|
||||||
|
const ListActions = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
navigate("/server_actions/scheduled/create");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TopToolbar>
|
||||||
|
<Button label="Create" onClick={handleCreate} startIcon={<AddIcon />} />
|
||||||
|
</TopToolbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScheduledCommandsList = () => {
|
||||||
|
const { data, isLoading, error } = useScheduledCommands();
|
||||||
|
|
||||||
|
const listContext = useList({
|
||||||
|
resource: "scheduled",
|
||||||
|
sort: { field: "scheduled_at", order: "DESC" },
|
||||||
|
perPage: 50,
|
||||||
|
data: data || [],
|
||||||
|
isLoading: isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourceContextProvider value="scheduled">
|
||||||
|
<ListContextProvider value={listContext}>
|
||||||
|
<ListActions />
|
||||||
|
<Paper>
|
||||||
|
<Datagrid
|
||||||
|
bulkActionButtons={false}
|
||||||
|
rowClick={(id: Identifier, resource: string, record: any) => {
|
||||||
|
if (!record) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.is_recurring) {
|
||||||
|
return `/server_actions/${resource}/${id}/show`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/server_actions/${resource}/${id}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField source="command" />
|
||||||
|
<TextField source="args" label="Arguments" />
|
||||||
|
<BooleanField source="is_recurring" label="Is recurring?" />
|
||||||
|
<DateField options={DATE_FORMAT} showTime source="scheduled_at" label="Run at (local time)" />
|
||||||
|
</Datagrid>
|
||||||
|
</Paper>
|
||||||
|
</ListContextProvider>
|
||||||
|
</ResourceContextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScheduledCommandsList;
|
@@ -0,0 +1,65 @@
|
|||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNotify, useDataProvider, useRecordContext } from "react-admin";
|
||||||
|
import { Button, Confirm } from "react-admin";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../../../../Context";
|
||||||
|
import { ScheduledCommand } from "../../../../../synapse/dataProvider";
|
||||||
|
|
||||||
|
const ScheduledDeleteButton = () => {
|
||||||
|
const record = useRecordContext() as ScheduledCommand;
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const notify = useNotify();
|
||||||
|
const theme = useTheme();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await dataProvider.deleteScheduledCommand(etkeccAdmin, record.id);
|
||||||
|
notify("scheduled_commands.action.delete_success", { type: "success" });
|
||||||
|
navigate("/server_actions");
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
|
notify(`Error: ${errorMessage}`, { type: "error" });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
sx={{ color: theme.palette.error.main }}
|
||||||
|
label="Delete"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isDeleting}
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
/>
|
||||||
|
<Confirm
|
||||||
|
isOpen={open}
|
||||||
|
title="Delete Scheduled Command"
|
||||||
|
content={`Are you sure you want to delete the command: ${record?.command || ""}?`}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onClose={handleCancel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScheduledDeleteButton;
|
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useDataProvider } from "react-admin";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../../../Context";
|
||||||
|
|
||||||
|
export const useRecurringCommands = () => {
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["recurringCommands"],
|
||||||
|
queryFn: () => dataProvider.getRecurringCommands(etkeccAdmin),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data, isLoading, error };
|
||||||
|
};
|
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useDataProvider } from "react-admin";
|
||||||
|
|
||||||
|
import { useAppContext } from "../../../../Context";
|
||||||
|
|
||||||
|
export const useScheduledCommands = () => {
|
||||||
|
const { etkeccAdmin } = useAppContext();
|
||||||
|
const dataProvider = useDataProvider();
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["scheduledCommands"],
|
||||||
|
queryFn: () => dataProvider.getScheduledCommands(etkeccAdmin),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data, isLoading, error };
|
||||||
|
};
|
@@ -1,15 +1,26 @@
|
|||||||
import { get } from "lodash";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import BlockIcon from "@mui/icons-material/Block";
|
import BlockIcon from "@mui/icons-material/Block";
|
||||||
import IconCancel from "@mui/icons-material/Cancel";
|
import IconCancel from "@mui/icons-material/Cancel";
|
||||||
import ClearIcon from "@mui/icons-material/Clear";
|
import ClearIcon from "@mui/icons-material/Clear";
|
||||||
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import DownloadingIcon from "@mui/icons-material/Downloading";
|
||||||
import FileOpenIcon from "@mui/icons-material/FileOpen";
|
import FileOpenIcon from "@mui/icons-material/FileOpen";
|
||||||
import LockIcon from "@mui/icons-material/Lock";
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||||
import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material";
|
import {
|
||||||
|
Grid2 as Grid,
|
||||||
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Tooltip,
|
||||||
|
Link,
|
||||||
|
} from "@mui/material";
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { get } from "lodash";
|
||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
BooleanInput,
|
BooleanInput,
|
||||||
Button,
|
Button,
|
||||||
@@ -28,23 +39,25 @@ import {
|
|||||||
useRefresh,
|
useRefresh,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { dateParser } from "./date";
|
|
||||||
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
|
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
|
||||||
import { getMediaUrl } from "../synapse/synapse";
|
import { dateParser } from "../utils/date";
|
||||||
import storage from "../storage";
|
import decodeURLComponent from "../utils/decodeURLComponent";
|
||||||
|
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||||
|
|
||||||
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
|
|
||||||
const DeleteMediaToolbar = (props: ToolbarProps) => (
|
const DeleteMediaToolbar = (props: ToolbarProps) => (
|
||||||
<Toolbar {...props}>
|
<Toolbar {...props}>
|
||||||
<SaveButton label="delete_media.action.send" icon={<DeleteSweepIcon />} />
|
<Tooltip title={translate("delete_media.helper.send")}>
|
||||||
<Button label="ra.action.cancel" onClick={onClose}>
|
<SaveButton label="delete_media.action.send" icon={<DeleteSweepIcon />} />
|
||||||
<IconCancel />
|
</Tooltip>
|
||||||
</Button>
|
<Tooltip title={translate("ra.action.cancel")}>
|
||||||
|
<Button label="ra.action.cancel" onClick={onClose}>
|
||||||
|
<IconCancel />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -54,24 +67,20 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
|
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
|
||||||
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
||||||
<DateTimeInput
|
<Tooltip title={translate("delete_media.helper.before_ts")}>
|
||||||
source="before_ts"
|
<DateTimeInput
|
||||||
label="delete_media.fields.before_ts"
|
source="before_ts"
|
||||||
defaultValue={0}
|
label="delete_media.fields.before_ts"
|
||||||
parse={dateParser}
|
defaultValue={0}
|
||||||
/>
|
parse={dateParser}
|
||||||
<NumberInput
|
/>
|
||||||
source="size_gt"
|
</Tooltip>
|
||||||
label="delete_media.fields.size_gt"
|
<Tooltip title={translate("delete_media.helper.size_gt")}>
|
||||||
defaultValue={0}
|
<NumberInput source="size_gt" label="delete_media.fields.size_gt" defaultValue={0} min={0} step={1024} />
|
||||||
min={0}
|
</Tooltip>
|
||||||
step={1024}
|
<Tooltip title={translate("delete_media.helper.keep_profiles")}>
|
||||||
/>
|
<BooleanInput source="keep_profiles" label="delete_media.fields.keep_profiles" defaultValue={true} />
|
||||||
<BooleanInput
|
</Tooltip>
|
||||||
source="keep_profiles"
|
|
||||||
label="delete_media.fields.keep_profiles"
|
|
||||||
defaultValue={true}
|
|
||||||
/>
|
|
||||||
</SimpleForm>
|
</SimpleForm>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -82,6 +91,7 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
|
const translate = useTranslate();
|
||||||
const dataProvider = useDataProvider<SynapseDataProvider>();
|
const dataProvider = useDataProvider<SynapseDataProvider>();
|
||||||
const { mutate: deleteMedia, isPending } = useMutation({
|
const { mutate: deleteMedia, isPending } = useMutation({
|
||||||
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
||||||
@@ -101,29 +111,112 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Tooltip title={translate("delete_media.helper.send")}>
|
||||||
{...props}
|
<Button
|
||||||
label="delete_media.action.send"
|
{...props}
|
||||||
onClick={openDialog}
|
label="delete_media.action.send"
|
||||||
disabled={isPending}
|
onClick={openDialog}
|
||||||
sx={{
|
disabled={isPending}
|
||||||
color: theme.palette.error.main,
|
sx={{
|
||||||
"&:hover": {
|
color: theme.palette.error.main,
|
||||||
backgroundColor: alpha(theme.palette.error.main, 0.12),
|
"&:hover": {
|
||||||
// Reset on mouse devices
|
backgroundColor: alpha(theme.palette.error.main, 0.12),
|
||||||
"@media (hover: none)": {
|
// Reset on mouse devices
|
||||||
backgroundColor: "transparent",
|
"@media (hover: none)": {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<DeleteSweepIcon />
|
||||||
<DeleteSweepIcon />
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
<DeleteMediaDialog open={open} onClose={closeDialog} onSubmit={deleteMedia} />
|
<DeleteMediaDialog open={open} onClose={closeDialog} onSubmit={deleteMedia} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PurgeRemoteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
|
||||||
|
const PurgeRemoteMediaToolbar = (props: ToolbarProps) => (
|
||||||
|
<Toolbar {...props}>
|
||||||
|
<Tooltip title={translate("purge_remote_media.helper.send")}>
|
||||||
|
<SaveButton label="purge_remote_media.action.send" icon={<DeleteSweepIcon />} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={translate("ra.action.cancel")}>
|
||||||
|
<Button label="ra.action.cancel" onClick={onClose}>
|
||||||
|
<IconCancel />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose}>
|
||||||
|
<DialogTitle>{translate("purge_remote_media.action.send")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{translate("purge_remote_media.helper.send")}</DialogContentText>
|
||||||
|
<SimpleForm toolbar={<PurgeRemoteMediaToolbar />} onSubmit={onSubmit}>
|
||||||
|
<DateTimeInput
|
||||||
|
source="before_ts"
|
||||||
|
label="purge_remote_media.fields.before_ts"
|
||||||
|
defaultValue={0}
|
||||||
|
parse={dateParser}
|
||||||
|
/>
|
||||||
|
</SimpleForm>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PurgeRemoteMediaButton = (props: ButtonProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const notify = useNotify();
|
||||||
|
const translate = useTranslate();
|
||||||
|
const dataProvider = useDataProvider<SynapseDataProvider>();
|
||||||
|
const { mutate: purgeRemoteMedia, isPending } = useMutation({
|
||||||
|
mutationFn: (values: DeleteMediaParams) => dataProvider.purgeRemoteMedia(values),
|
||||||
|
onSuccess: () => {
|
||||||
|
notify("purge_remote_media.action.send_success");
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notify("purge_remote_media.action.send_failure", {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDialog = () => setOpen(true);
|
||||||
|
const closeDialog = () => setOpen(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title={translate("purge_remote_media.helper.send")}>
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
label="purge_remote_media.action.send"
|
||||||
|
onClick={openDialog}
|
||||||
|
disabled={isPending}
|
||||||
|
sx={{
|
||||||
|
"&:hover": {
|
||||||
|
// Reset on mouse devices
|
||||||
|
"@media (hover: none)": {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteSweepIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<PurgeRemoteMediaDialog open={open} onClose={closeDialog} onSubmit={purgeRemoteMedia} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ProtectMediaButton = (props: ButtonProps) => {
|
export const ProtectMediaButton = (props: ButtonProps) => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
@@ -311,48 +404,139 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ViewMediaButton = ({ media_id, label }) => {
|
export const ViewMediaButton = ({ mxcURL, label, uploadName, mimetype }) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
const url = getMediaUrl(media_id);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const notify = useNotify();
|
||||||
|
const isImage = mimetype && mimetype.startsWith("image/");
|
||||||
|
|
||||||
|
const openFileInNewTab = (blobURL: string) => {
|
||||||
|
const anchorElement = document.createElement("a");
|
||||||
|
anchorElement.href = blobURL;
|
||||||
|
anchorElement.target = "_blank";
|
||||||
|
document.body.appendChild(anchorElement);
|
||||||
|
anchorElement.click();
|
||||||
|
document.body.removeChild(anchorElement);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobURL), 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = async (blobURL: string) => {
|
||||||
|
console.log("downloadFile", blobURL, uploadName);
|
||||||
|
const anchorElement = document.createElement("a");
|
||||||
|
anchorElement.href = blobURL;
|
||||||
|
anchorElement.download = uploadName;
|
||||||
|
document.body.appendChild(anchorElement);
|
||||||
|
anchorElement.click();
|
||||||
|
document.body.removeChild(anchorElement);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobURL), 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = async (preview: boolean) => {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetchAuthenticatedMedia(mxcURL, "original");
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobURL = URL.createObjectURL(blob);
|
||||||
|
if (preview) {
|
||||||
|
openFileInNewTab(blobURL);
|
||||||
|
} else {
|
||||||
|
downloadFile(blobURL);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const body = await response.json();
|
||||||
|
notify("resources.room_media.action.error", {
|
||||||
|
messageArgs: {
|
||||||
|
errcode: body.errcode,
|
||||||
|
errstatus: response.status,
|
||||||
|
error: body.error,
|
||||||
|
},
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ whiteSpace: "pre" }}>
|
<>
|
||||||
<Tooltip title={translate("resources.users_media.action.open")}>
|
<Box display="flex" alignItems="center">
|
||||||
<span>
|
<Tooltip title={translate("resources.users_media.action.open")}>
|
||||||
<Button
|
<span>
|
||||||
component={Link}
|
{isImage && (
|
||||||
to={url}
|
<Button
|
||||||
target="_blank"
|
disabled={loading}
|
||||||
rel="noopener"
|
onClick={() => handleFile(true)}
|
||||||
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }}
|
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
|
||||||
>
|
>
|
||||||
<FileOpenIcon />
|
{loading ? <DownloadingIcon /> : <FileOpenIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
)}
|
||||||
</Tooltip>
|
</span>
|
||||||
{label}
|
</Tooltip>
|
||||||
</Box>
|
<Button
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => handleFile(false)}
|
||||||
|
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
{loading ? <DownloadingIcon /> : <DownloadIcon />}
|
||||||
|
</Button>
|
||||||
|
<span>{label}</span>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MediaIDField = ({ source }) => {
|
export const MediaIDField = ({ source }) => {
|
||||||
const homeserver = storage.getItem("home_server");
|
const translate = useTranslate();
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
if (!record) return null;
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const homeserver = localStorage.getItem("home_server");
|
||||||
|
|
||||||
const src = get(record, source)?.toString();
|
const mediaID = get(record, source)?.toString();
|
||||||
if (!src) return null;
|
if (!mediaID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <ViewMediaButton media_id={`${homeserver}/${src}`} label={src} />;
|
let uploadName = mediaID;
|
||||||
|
if (get(record, "upload_name")) {
|
||||||
|
uploadName = decodeURLComponent(get(record, "upload_name")?.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mxcURL = mediaID;
|
||||||
|
if (!mediaID.startsWith(`mxc://${homeserver}`)) {
|
||||||
|
// this is user's media, where mediaID doesn't have the mxc://home_server/ prefix as it has in the rooms
|
||||||
|
mxcURL = `mxc://${homeserver}/${mediaID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={translate("resources.users_media.action.open")}>
|
||||||
|
<ViewMediaButton mxcURL={mxcURL} label={mediaID} uploadName={uploadName} mimetype={record.media_type} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MXCField = ({ source }) => {
|
export const ReportMediaContent = ({ source }) => {
|
||||||
|
const translate = useTranslate();
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
if (!record) return null;
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const src = get(record, source)?.toString();
|
const mxcURL = get(record, source)?.toString();
|
||||||
if (!src) return null;
|
if (!mxcURL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const media_id = src.replace("mxc://", "");
|
let uploadName = "";
|
||||||
|
if (get(record, "event_json.content.body")) {
|
||||||
|
uploadName = decodeURLComponent(get(record, "event_json.content.body")?.toString());
|
||||||
|
}
|
||||||
|
|
||||||
return <ViewMediaButton media_id={media_id} label={src} />;
|
return (
|
||||||
|
<Tooltip title={translate("resources.users_media.action.open")}>
|
||||||
|
<ViewMediaButton mxcURL={mxcURL} label={mxcURL} uploadName={uploadName} mimetype={record.media_type} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
50
src/components/user-import/ConflictModeCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NativeSelect, Paper } from "@mui/material";
|
||||||
|
import { CardContent, CardHeader, Container } from "@mui/material";
|
||||||
|
import { useTranslate } from "ra-core";
|
||||||
|
import { ChangeEventHandler } from "react";
|
||||||
|
|
||||||
|
import { ParsedStats, Progress } from "./types";
|
||||||
|
|
||||||
|
const TranslatableOption = ({ value, text }: { value: string; text: string }) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
return <option value={value}>{translate(text)}</option>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConflictModeCard = ({
|
||||||
|
stats,
|
||||||
|
importResults,
|
||||||
|
onConflictModeChanged,
|
||||||
|
conflictMode,
|
||||||
|
progress,
|
||||||
|
}: {
|
||||||
|
stats: ParsedStats | null;
|
||||||
|
importResults: any;
|
||||||
|
onConflictModeChanged: ChangeEventHandler<HTMLSelectElement>;
|
||||||
|
conflictMode: string;
|
||||||
|
progress: Progress;
|
||||||
|
}) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
|
||||||
|
if (!stats || importResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container sx={{ mb: 3 }}>
|
||||||
|
<Paper elevation={1}>
|
||||||
|
<CardHeader
|
||||||
|
title={translate("import_users.cards.conflicts.header")}
|
||||||
|
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<NativeSelect onChange={onConflictModeChanged} value={conflictMode} disabled={progress !== null}>
|
||||||
|
<TranslatableOption value="stop" text="import_users.cards.conflicts.mode.stop" />
|
||||||
|
<TranslatableOption value="skip" text="import_users.cards.conflicts.mode.skip" />
|
||||||
|
</NativeSelect>
|
||||||
|
</CardContent>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConflictModeCard;
|
36
src/components/user-import/ErrorsCard.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Container, Paper, CardHeader, CardContent, Stack, Typography } from "@mui/material";
|
||||||
|
import { useTranslate } from "ra-core";
|
||||||
|
|
||||||
|
const ErrorsCard = ({ errors }: { errors: string[] }) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
|
||||||
|
if (errors.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container sx={{ mb: 3 }}>
|
||||||
|
<Paper elevation={1}>
|
||||||
|
<CardHeader
|
||||||
|
title={translate("import_users.error.error")}
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: "error.main",
|
||||||
|
color: "error.main",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{errors.map((e, idx) => (
|
||||||
|
<Typography key={idx} color="error">
|
||||||
|
{e}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorsCard;
|
95
src/components/user-import/ResultsCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Container,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Button, Link, useTranslate } from "react-admin";
|
||||||
|
|
||||||
|
import { ImportResult } from "./types";
|
||||||
|
|
||||||
|
const ResultsCard = ({
|
||||||
|
importResults,
|
||||||
|
downloadSkippedRecords,
|
||||||
|
}: {
|
||||||
|
importResults: ImportResult | null;
|
||||||
|
downloadSkippedRecords: () => void;
|
||||||
|
}) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
|
||||||
|
if (!importResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Paper>
|
||||||
|
<CardHeader
|
||||||
|
title={translate("import_users.cards.results.header")}
|
||||||
|
pb={0}
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography key="total" color="text.primary">
|
||||||
|
{translate("import_users.cards.results.total", importResults.totalRecordCount)}
|
||||||
|
</Typography>
|
||||||
|
<Typography key="successful" color="success.main">
|
||||||
|
{translate("import_users.cards.results.successful", importResults.succeededRecords.length)}
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
{importResults.succeededRecords.map(record => (
|
||||||
|
<ListItem key={record.id}>
|
||||||
|
<ListItemText primary={record.displayname} />
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
{importResults.skippedRecords.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography key="skipped" color="warning.main">
|
||||||
|
{translate("import_users.cards.results.skipped", importResults.skippedRecords.length)}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={downloadSkippedRecords}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
label={translate("import_users.cards.results.download_skipped")}
|
||||||
|
></Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{importResults.erroredRecords.length > 0 && (
|
||||||
|
<Typography key="errored" color="error.main">
|
||||||
|
{translate("import_users.cards.results.skipped", importResults.erroredRecords.length)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importResults.wasDryRun && (
|
||||||
|
<Alert severity="warning" key="simulated">
|
||||||
|
{translate("import_users.cards.results.simulated_only")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Paper>
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Link to="/users">
|
||||||
|
<Button variant="outlined" startIcon={<ArrowBackIcon />} label={translate("ra.action.back")} />
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResultsCard;
|
50
src/components/user-import/StartImportCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Checkbox, Paper, Container } from "@mui/material";
|
||||||
|
import { CardActions, FormControlLabel } from "@mui/material";
|
||||||
|
import { useTranslate } from "ra-core";
|
||||||
|
import { ChangeEventHandler } from "react";
|
||||||
|
|
||||||
|
import { Progress, ImportLine, ImportResult } from "./types";
|
||||||
|
|
||||||
|
const StartImportCard = ({
|
||||||
|
csvData,
|
||||||
|
importResults,
|
||||||
|
progress,
|
||||||
|
dryRun,
|
||||||
|
onDryRunModeChanged,
|
||||||
|
runImport,
|
||||||
|
}: {
|
||||||
|
csvData: ImportLine[];
|
||||||
|
importResults: ImportResult | null;
|
||||||
|
progress: Progress;
|
||||||
|
dryRun: boolean;
|
||||||
|
onDryRunModeChanged: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
runImport: () => void;
|
||||||
|
}) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
if (!csvData || csvData.length === 0 || importResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Paper>
|
||||||
|
<CardActions>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={dryRun} onChange={onDryRunModeChanged} disabled={progress !== null} />}
|
||||||
|
label={translate("import_users.cards.startImport.simulate_only")}
|
||||||
|
/>
|
||||||
|
<Button variant="contained" size="large" onClick={runImport} disabled={progress !== null}>
|
||||||
|
{translate("import_users.cards.startImport.run_import")}
|
||||||
|
</Button>
|
||||||
|
{progress !== null ? (
|
||||||
|
<div>
|
||||||
|
{progress.done} of {progress.limit} done
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardActions>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StartImportCard;
|
100
src/components/user-import/StatsCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Card, Paper, Stack, CardContent, CardHeader, Container, Typography } from "@mui/material";
|
||||||
|
import { NativeSelect } from "@mui/material";
|
||||||
|
import { FormControlLabel } from "@mui/material";
|
||||||
|
import { Checkbox } from "@mui/material";
|
||||||
|
import { useTranslate } from "ra-core";
|
||||||
|
import { ChangeEventHandler } from "react";
|
||||||
|
|
||||||
|
import { ParsedStats, Progress } from "./types";
|
||||||
|
|
||||||
|
const StatsCard = ({
|
||||||
|
stats,
|
||||||
|
progress,
|
||||||
|
importResults,
|
||||||
|
useridMode,
|
||||||
|
passwordMode,
|
||||||
|
onUseridModeChanged,
|
||||||
|
onPasswordModeChange,
|
||||||
|
}: {
|
||||||
|
stats: ParsedStats | null;
|
||||||
|
progress: Progress;
|
||||||
|
importResults: any;
|
||||||
|
useridMode: string;
|
||||||
|
passwordMode: boolean;
|
||||||
|
onUseridModeChanged: ChangeEventHandler<HTMLSelectElement>;
|
||||||
|
onPasswordModeChange: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
}) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container sx={{ mb: 3 }}>
|
||||||
|
<Paper>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title={translate("import_users.cards.importstats.header")}
|
||||||
|
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography>{translate("import_users.cards.importstats.users_total", stats.total)}</Typography>
|
||||||
|
<Typography>{translate("import_users.cards.importstats.guest_count", stats.is_guest)}</Typography>
|
||||||
|
<Typography>{translate("import_users.cards.importstats.admin_count", stats.admin)}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
<CardHeader
|
||||||
|
title={translate("import_users.cards.ids.header")}
|
||||||
|
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography>
|
||||||
|
{stats.id === stats.total
|
||||||
|
? translate("import_users.cards.ids.all_ids_present")
|
||||||
|
: translate("import_users.cards.ids.count_ids_present", stats.id)}
|
||||||
|
</Typography>
|
||||||
|
{stats.id > 0 && (
|
||||||
|
<NativeSelect onChange={onUseridModeChanged} value={useridMode} disabled={progress !== null}>
|
||||||
|
<option value={"ignore"}>{translate("import_users.cards.ids.mode.ignore")}</option>
|
||||||
|
<option value={"update"}>{translate("import_users.cards.ids.mode.update")}</option>
|
||||||
|
</NativeSelect>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
<CardHeader
|
||||||
|
title={translate("import_users.cards.passwords.header")}
|
||||||
|
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography>
|
||||||
|
{stats.password === stats.total
|
||||||
|
? translate("import_users.cards.passwords.all_passwords_present")
|
||||||
|
: translate("import_users.cards.passwords.count_passwords_present", stats.password)}
|
||||||
|
</Typography>
|
||||||
|
{stats.password > 0 && (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox checked={passwordMode} disabled={progress !== null} onChange={onPasswordModeChange} />
|
||||||
|
}
|
||||||
|
label={translate("import_users.cards.passwords.use_passwords")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsCard;
|
44
src/components/user-import/UploadCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { CardHeader, CardContent, Container, Link, Stack, Typography, Paper } from "@mui/material";
|
||||||
|
import { useTranslate } from "ra-core";
|
||||||
|
import { ChangeEventHandler } from "react";
|
||||||
|
|
||||||
|
import { Progress } from "./types";
|
||||||
|
|
||||||
|
const UploadCard = ({
|
||||||
|
importResults,
|
||||||
|
onFileChange,
|
||||||
|
progress,
|
||||||
|
}: {
|
||||||
|
importResults: any;
|
||||||
|
onFileChange: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
progress: Progress;
|
||||||
|
}) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
if (importResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container sx={{ mb: 3 }}>
|
||||||
|
<Paper elevation={1}>
|
||||||
|
<CardHeader
|
||||||
|
title={translate("import_users.cards.upload.header")}
|
||||||
|
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography>
|
||||||
|
{translate("import_users.cards.upload.explanation")}
|
||||||
|
<Link href="./data/example.csv" sx={{ ml: 1 }}>
|
||||||
|
example.csv
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
<input type="file" onChange={onFileChange} disabled={progress !== null} />
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadCard;
|
69
src/components/user-import/UserImport.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Stack } from "@mui/material";
|
||||||
|
import { useTranslate } from "ra-core";
|
||||||
|
import { Title } from "react-admin";
|
||||||
|
|
||||||
|
import ConflictModeCard from "./ConflictModeCard";
|
||||||
|
import ErrorsCard from "./ErrorsCard";
|
||||||
|
import ResultsCard from "./ResultsCard";
|
||||||
|
import StartImportCard from "./StartImportCard";
|
||||||
|
import StatsCard from "./StatsCard";
|
||||||
|
import UploadCard from "./UploadCard";
|
||||||
|
import useImportFile from "./useImportFile";
|
||||||
|
|
||||||
|
const UserImport = () => {
|
||||||
|
const {
|
||||||
|
csvData,
|
||||||
|
dryRun,
|
||||||
|
importResults,
|
||||||
|
progress,
|
||||||
|
errors,
|
||||||
|
stats,
|
||||||
|
conflictMode,
|
||||||
|
passwordMode,
|
||||||
|
useridMode,
|
||||||
|
onFileChange,
|
||||||
|
onDryRunModeChanged,
|
||||||
|
runImport,
|
||||||
|
onConflictModeChanged,
|
||||||
|
onPasswordModeChange,
|
||||||
|
onUseridModeChanged,
|
||||||
|
downloadSkippedRecords,
|
||||||
|
} = useImportFile();
|
||||||
|
|
||||||
|
const translate = useTranslate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} mt={3} direction="column">
|
||||||
|
<Title defaultTitle={translate("import_users.title")} />
|
||||||
|
<UploadCard importResults={importResults} onFileChange={onFileChange} progress={progress} />
|
||||||
|
<ErrorsCard errors={errors} />
|
||||||
|
<ConflictModeCard
|
||||||
|
stats={stats}
|
||||||
|
importResults={importResults}
|
||||||
|
conflictMode={conflictMode}
|
||||||
|
onConflictModeChanged={onConflictModeChanged}
|
||||||
|
progress={progress}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
stats={stats}
|
||||||
|
progress={progress}
|
||||||
|
importResults={importResults}
|
||||||
|
passwordMode={passwordMode}
|
||||||
|
useridMode={useridMode}
|
||||||
|
onPasswordModeChange={onPasswordModeChange}
|
||||||
|
onUseridModeChanged={onUseridModeChanged}
|
||||||
|
/>
|
||||||
|
<StartImportCard
|
||||||
|
csvData={csvData}
|
||||||
|
importResults={importResults}
|
||||||
|
progress={progress}
|
||||||
|
dryRun={dryRun}
|
||||||
|
onDryRunModeChanged={onDryRunModeChanged}
|
||||||
|
runImport={runImport}
|
||||||
|
/>
|
||||||
|
<ResultsCard importResults={importResults} downloadSkippedRecords={downloadSkippedRecords} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserImport;
|
47
src/components/user-import/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { RaRecord } from "react-admin";
|
||||||
|
|
||||||
|
export interface ImportLine {
|
||||||
|
id: string;
|
||||||
|
displayname: string;
|
||||||
|
user_type?: string;
|
||||||
|
name?: string;
|
||||||
|
deactivated?: boolean;
|
||||||
|
is_guest?: boolean;
|
||||||
|
admin?: boolean;
|
||||||
|
is_admin?: boolean;
|
||||||
|
password?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedStats {
|
||||||
|
user_types: Record<string, number>;
|
||||||
|
is_guest: number;
|
||||||
|
admin: number;
|
||||||
|
deactivated: number;
|
||||||
|
password: number;
|
||||||
|
avatar_url: number;
|
||||||
|
id: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeStats {
|
||||||
|
total: number;
|
||||||
|
id: number;
|
||||||
|
is_guest: number;
|
||||||
|
admin: number;
|
||||||
|
password: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Progress = {
|
||||||
|
done: number;
|
||||||
|
limit: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
skippedRecords: RaRecord[];
|
||||||
|
erroredRecords: RaRecord[];
|
||||||
|
succeededRecords: RaRecord[];
|
||||||
|
totalRecordCount: number;
|
||||||
|
changeStats: ChangeStats;
|
||||||
|
wasDryRun: boolean;
|
||||||
|
}
|
377
src/components/user-import/useImportFile.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { parse as parseCsv, unparse as unparseCsv, ParseResult } from "papaparse";
|
||||||
|
import { ChangeEvent, useState } from "react";
|
||||||
|
import { useTranslate, useNotify, HttpError } from "react-admin";
|
||||||
|
|
||||||
|
import { ImportLine, ParsedStats, Progress, ImportResult, ChangeStats } from "./types";
|
||||||
|
import dataProvider from "../../synapse/dataProvider";
|
||||||
|
import { returnMXID } from "../../utils/mxid";
|
||||||
|
import { generateRandomMXID } from "../../utils/mxid";
|
||||||
|
import { generateRandomPassword } from "../../utils/password";
|
||||||
|
|
||||||
|
const LOGGING = true;
|
||||||
|
|
||||||
|
const EXPECTED_FIELDS = ["id", "displayname"].sort();
|
||||||
|
|
||||||
|
const useImportFile = () => {
|
||||||
|
const [csvData, setCsvData] = useState<ImportLine[]>([]);
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
const [stats, setStats] = useState<ParsedStats | null>(null);
|
||||||
|
const [dryRun, setDryRun] = useState(true);
|
||||||
|
|
||||||
|
const [progress, setProgress] = useState<Progress>(null);
|
||||||
|
|
||||||
|
const [importResults, setImportResults] = useState<ImportResult | null>(null);
|
||||||
|
const [skippedRecords, setSkippedRecords] = useState<string>("");
|
||||||
|
|
||||||
|
const [conflictMode, setConflictMode] = useState<"stop" | "skip">("stop");
|
||||||
|
const [passwordMode, setPasswordMode] = useState(true);
|
||||||
|
const [useridMode, setUseridMode] = useState<"update" | "ignore">("update");
|
||||||
|
|
||||||
|
const translate = useTranslate();
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
|
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (progress !== null) return;
|
||||||
|
|
||||||
|
setCsvData([]);
|
||||||
|
setErrors([]);
|
||||||
|
setStats(null);
|
||||||
|
setImportResults(null);
|
||||||
|
const file = e.target.files ? e.target.files[0] : null;
|
||||||
|
if (!file) return;
|
||||||
|
/* Let's refuse some unreasonably big files instead of freezing
|
||||||
|
* up the browser */
|
||||||
|
if (file.size > 100000000) {
|
||||||
|
const message = translate("import_users.errors.unreasonably_big", {
|
||||||
|
size: (file.size / (1024 * 1024)).toFixed(2),
|
||||||
|
});
|
||||||
|
notify(message);
|
||||||
|
setErrors([message]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parseCsv<ImportLine>(file, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true /* especially for a final EOL in the csv file */,
|
||||||
|
complete: result => {
|
||||||
|
if (result.errors) {
|
||||||
|
setErrors(result.errors.map(e => e.toString()));
|
||||||
|
}
|
||||||
|
/* Papaparse is very lenient, we may be able to salvage
|
||||||
|
* the data in the file. */
|
||||||
|
verifyCsv(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setErrors(["Unknown error"]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>) => {
|
||||||
|
/* First, verify the presence of required fields */
|
||||||
|
meta.fields = meta.fields?.map(f => f.trim().toLowerCase());
|
||||||
|
const missingFields = EXPECTED_FIELDS.filter(eF => !meta.fields?.find(mF => eF === mF));
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
setErrors([translate("import_users.error.required_field", { field: missingFields[0] })]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collect some stats to prevent sneaky csv files from adding admin
|
||||||
|
users or something.
|
||||||
|
*/
|
||||||
|
const stats: ParsedStats = {
|
||||||
|
user_types: { default: 0 },
|
||||||
|
is_guest: 0,
|
||||||
|
admin: 0,
|
||||||
|
deactivated: 0,
|
||||||
|
password: 0,
|
||||||
|
avatar_url: 0,
|
||||||
|
id: 0,
|
||||||
|
|
||||||
|
total: data.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessages = errors.map(e => e.message);
|
||||||
|
// sanitize the data first
|
||||||
|
data = data.map(line => {
|
||||||
|
const newLine = {} as ImportLine;
|
||||||
|
for (const [key, value] of Object.entries(line)) {
|
||||||
|
newLine[key.trim().toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
return newLine;
|
||||||
|
});
|
||||||
|
|
||||||
|
// process the data
|
||||||
|
data.forEach((line, idx) => {
|
||||||
|
if (line.user_type === undefined || line.user_type === "") {
|
||||||
|
stats.user_types.default++;
|
||||||
|
} else {
|
||||||
|
stats.user_types[line.user_type] += 1;
|
||||||
|
}
|
||||||
|
/* XXX correct the csv export that react-admin offers for the users
|
||||||
|
* resource so it gives sensible field names and doesn't duplicate
|
||||||
|
* id as "name"?
|
||||||
|
*/
|
||||||
|
if (meta.fields?.includes("name")) {
|
||||||
|
delete line.name;
|
||||||
|
}
|
||||||
|
if (meta.fields?.includes("user_type")) {
|
||||||
|
delete line.user_type;
|
||||||
|
}
|
||||||
|
if (meta.fields?.includes("is_admin")) {
|
||||||
|
delete line.is_admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
["is_guest", "admin", "deactivated"].forEach(f => {
|
||||||
|
if (line[f] === "true") {
|
||||||
|
stats[f]++;
|
||||||
|
line[f] = true; // we need true booleans instead of strings
|
||||||
|
} else {
|
||||||
|
if (line[f] !== "false" && line[f] !== "") {
|
||||||
|
console.log("invalid value", line[f], "for field " + f + " in row " + idx);
|
||||||
|
errorMessages.push(
|
||||||
|
translate("import_users.error.invalid_value", {
|
||||||
|
field: f,
|
||||||
|
row: idx,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
line[f] = false; // default values to false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (line.password !== undefined && line.password !== "") {
|
||||||
|
stats.password++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.avatar_url !== undefined && line.avatar_url !== "") {
|
||||||
|
stats.avatar_url++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.id !== undefined && line.id !== "") {
|
||||||
|
stats.id++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errorMessages.length > 0) {
|
||||||
|
setErrors(errorMessages);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStats(stats);
|
||||||
|
setCsvData(data);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConflictModeChanged = async (e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
if (progress !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = e.target.value as "stop" | "skip";
|
||||||
|
setConflictMode(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPasswordModeChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (progress !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordMode(e.target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUseridModeChanged = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
if (progress !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = e.target.value as "update" | "ignore";
|
||||||
|
setUseridMode(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDryRunModeChanged = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (progress !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDryRun(e.target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runImport = async () => {
|
||||||
|
if (progress !== null) {
|
||||||
|
notify("import_users.errors.already_in_progress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await doImport();
|
||||||
|
setImportResults(results);
|
||||||
|
// offer CSV download of skipped or errored records
|
||||||
|
// (so that the user doesn't have to filter out successful
|
||||||
|
// records manually when fixing stuff in the CSV)
|
||||||
|
setSkippedRecords(unparseCsv(results.skippedRecords));
|
||||||
|
if (LOGGING) console.log("Skipped records:");
|
||||||
|
if (LOGGING) console.log(skippedRecords);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doImport = async (): Promise<ImportResult> => {
|
||||||
|
const skippedRecords: ImportLine[] = [];
|
||||||
|
const erroredRecords: ImportLine[] = [];
|
||||||
|
const succeededRecords: ImportLine[] = [];
|
||||||
|
const changeStats: ChangeStats = {
|
||||||
|
total: 0,
|
||||||
|
id: 0,
|
||||||
|
is_guest: 0,
|
||||||
|
admin: 0,
|
||||||
|
password: 0,
|
||||||
|
};
|
||||||
|
let entriesDone = 0;
|
||||||
|
const entriesCount = csvData.length;
|
||||||
|
try {
|
||||||
|
setProgress({ done: entriesDone, limit: entriesCount });
|
||||||
|
for (const entry of csvData) {
|
||||||
|
const userRecord = { ...entry };
|
||||||
|
// No need to do a bunch of cryptographic random number getting if
|
||||||
|
// we are using neither a generated password nor a generated user id.
|
||||||
|
if (useridMode === "ignore" || userRecord.id === undefined || userRecord.id === "") {
|
||||||
|
userRecord.id = generateRandomMXID();
|
||||||
|
}
|
||||||
|
if (passwordMode === false || entry.password === undefined || entry.password === "") {
|
||||||
|
userRecord.password = generateRandomPassword();
|
||||||
|
}
|
||||||
|
// we want to ensure that the ID is always full MXID, otherwise randomly-generated MXIDs will be in the full
|
||||||
|
// form, but the ones from the CSV will be localpart-only.
|
||||||
|
userRecord.id = returnMXID(userRecord.id);
|
||||||
|
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
|
||||||
|
|
||||||
|
/* For these modes we will consider the ID that's in the record.
|
||||||
|
* If the mode is "stop", we will not continue adding more records, and
|
||||||
|
* we will offer information on what was already added and what was
|
||||||
|
* skipped.
|
||||||
|
*
|
||||||
|
* If the mode is "skip", we record the record for later, but don't
|
||||||
|
* send it to the server.
|
||||||
|
*
|
||||||
|
* If the mode is "update", we change fields that are reasonable to
|
||||||
|
* update.
|
||||||
|
* - If the "password mode" is "true" (i.e. "use passwords from csv"):
|
||||||
|
* - if the record has a password
|
||||||
|
* - send the password along with the record
|
||||||
|
* - if the record has no password
|
||||||
|
* - generate a new password
|
||||||
|
* - If the "password mode" is "false"
|
||||||
|
* - never generate a new password to update existing users with
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* We just act as if there are no IDs in the CSV, so every user will be
|
||||||
|
* created anew.
|
||||||
|
* We do a simple retry loop so that an accidental hit on an existing ID
|
||||||
|
* doesn't trip us up.
|
||||||
|
*/
|
||||||
|
if (LOGGING) console.log("will check for existence of record " + JSON.stringify(userRecord));
|
||||||
|
let retries = 0;
|
||||||
|
const submitRecord = async (recordData: ImportLine) => {
|
||||||
|
try {
|
||||||
|
const response = await dataProvider.getOne("users", { id: recordData.id });
|
||||||
|
|
||||||
|
if (LOGGING) console.log("already existed");
|
||||||
|
|
||||||
|
if (conflictMode === "stop") {
|
||||||
|
throw new Error(
|
||||||
|
translate("import_users.error.id_exits", {
|
||||||
|
id: recordData.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflictMode === "skip" || useridMode === "update") {
|
||||||
|
skippedRecords.push(recordData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRecordData = Object.assign({}, recordData, {
|
||||||
|
id: generateRandomMXID(),
|
||||||
|
});
|
||||||
|
retries++;
|
||||||
|
|
||||||
|
if (retries > 512) {
|
||||||
|
console.warn("retry loop got stuck? pathological situation?");
|
||||||
|
skippedRecords.push(recordData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await submitRecord(newRecordData);
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof HttpError) || (e.status && e.status !== 404)) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LOGGING) console.log("OK to create record " + recordData.id + " (" + recordData.displayname + ").");
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await dataProvider.create("users", { data: recordData });
|
||||||
|
}
|
||||||
|
succeededRecords.push(recordData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await submitRecord(userRecord);
|
||||||
|
entriesDone++;
|
||||||
|
setProgress({ done: entriesDone, limit: csvData.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(null);
|
||||||
|
} catch (e) {
|
||||||
|
setErrors([
|
||||||
|
translate("import_users.error.at_entry", {
|
||||||
|
entry: entriesDone + 1,
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setProgress(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skippedRecords,
|
||||||
|
erroredRecords,
|
||||||
|
succeededRecords,
|
||||||
|
totalRecordCount: entriesCount,
|
||||||
|
changeStats,
|
||||||
|
wasDryRun: dryRun,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadSkippedRecords = () => {
|
||||||
|
const element = document.createElement("a");
|
||||||
|
console.log(skippedRecords);
|
||||||
|
const file = new Blob([skippedRecords], {
|
||||||
|
type: "text/comma-separated-values",
|
||||||
|
});
|
||||||
|
element.href = URL.createObjectURL(file);
|
||||||
|
element.download = "skippedRecords.csv";
|
||||||
|
document.body.appendChild(element); // Required for this to work in FireFox
|
||||||
|
element.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
csvData,
|
||||||
|
dryRun,
|
||||||
|
onDryRunModeChanged,
|
||||||
|
runImport,
|
||||||
|
progress,
|
||||||
|
importResults,
|
||||||
|
errors,
|
||||||
|
stats,
|
||||||
|
conflictMode,
|
||||||
|
passwordMode,
|
||||||
|
useridMode,
|
||||||
|
onConflictModeChanged,
|
||||||
|
onPasswordModeChange,
|
||||||
|
onUseridModeChanged,
|
||||||
|
onFileChange,
|
||||||
|
downloadSkippedRecords,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useImportFile;
|
145
src/i18n/de.ts
@@ -2,7 +2,7 @@ import { formalGermanMessages } from "@haleos/ra-language-german";
|
|||||||
|
|
||||||
import { SynapseTranslationMessages } from ".";
|
import { SynapseTranslationMessages } from ".";
|
||||||
|
|
||||||
const de: SynapseTranslationMessages = {
|
const fixedGermanMessages = {
|
||||||
...formalGermanMessages,
|
...formalGermanMessages,
|
||||||
ra: {
|
ra: {
|
||||||
...formalGermanMessages.ra,
|
...formalGermanMessages.ra,
|
||||||
@@ -10,22 +10,60 @@ const de: SynapseTranslationMessages = {
|
|||||||
...formalGermanMessages.ra.navigation,
|
...formalGermanMessages.ra.navigation,
|
||||||
no_filtered_results: "Keine Ergebnisse",
|
no_filtered_results: "Keine Ergebnisse",
|
||||||
clear_filters: "Alle Filter entfernen",
|
clear_filters: "Alle Filter entfernen",
|
||||||
|
add_filter: "Filter hinzufügen",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
...formalGermanMessages.ra.auth,
|
||||||
|
email: "E-Mail",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
...formalGermanMessages.ra.action,
|
||||||
|
update_application: "Anwendung aktualisieren",
|
||||||
|
select_all_button: "Alle auswählen",
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
...formalGermanMessages.ra.page,
|
||||||
|
empty: "Leer",
|
||||||
|
access_denied: "Zugriff verweigert",
|
||||||
|
authentication_error: "Authentifizierungsfehler",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
...formalGermanMessages.ra.message,
|
||||||
|
access_denied: "Sie haben nicht die erforderlichen Berechtigungen um auf diese Seite zuzugreifen.",
|
||||||
|
authentication_error:
|
||||||
|
"Der Authentifizierungsserver hat einen Fehler zurückgegeben und Ihre Anmeldedaten konnten nicht überprüft werden.",
|
||||||
|
select_all_limit_reached:
|
||||||
|
"Es gibt zu viele Elemente, um sie alle auszuwählen. Es wurden nur die ersten %{max} Elemente ausgewählt.",
|
||||||
|
placeholder_data_warning: "Netzwerkproblem: Datenaktualisierung fehlgeschlagen.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const de: SynapseTranslationMessages = {
|
||||||
|
...fixedGermanMessages,
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "Heimserver URL",
|
base_url: "Heimserver URL",
|
||||||
welcome: "Willkommen bei Synapse-admin",
|
welcome: "Willkommen bei Synapse Admin",
|
||||||
server_version: "Synapse Version",
|
server_version: "Synapse Version",
|
||||||
supports_specs: "unterstützt Matrix-Specs",
|
supports_specs: "unterstützt Matrix-Specs",
|
||||||
username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'",
|
username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'",
|
||||||
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
|
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
|
||||||
url_error: "Keine gültige Matrix Server URL",
|
url_error: "Keine gültige Matrix Server URL",
|
||||||
sso_sign_in: "Anmeldung mit SSO",
|
sso_sign_in: "Anmeldung mit SSO",
|
||||||
|
credentials: "Anmeldedaten",
|
||||||
|
access_token: "Zugriffstoken",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "Sie verwenden ein bestehendes Matrix-Zugriffstoken.",
|
||||||
|
content:
|
||||||
|
"Möchten Sie diese Sitzung (die anderswo, z.B. in einem Matrix-Client, verwendet werden könnte) beenden oder sich nur vom Admin-Panel abmelden?",
|
||||||
|
confirm: "Sitzung beenden",
|
||||||
|
cancel: "Nur vom Admin-Panel abmelden",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
|
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
|
||||||
tabs: { sso: "SSO" },
|
tabs: { sso: "SSO", experimental: "Experimentell", limits: "Rate Limits", account_data: "Kontodaten" },
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
details: "Raumdetails",
|
details: "Raumdetails",
|
||||||
@@ -34,6 +72,7 @@ const de: SynapseTranslationMessages = {
|
|||||||
members: "Mitglieder",
|
members: "Mitglieder",
|
||||||
detail: "Details",
|
detail: "Details",
|
||||||
permission: "Berechtigungen",
|
permission: "Berechtigungen",
|
||||||
|
media: "Medien",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reports: { tabs: { basic: "Allgemein", detail: "Details" } },
|
reports: { tabs: { basic: "Allgemein", detail: "Details" } },
|
||||||
@@ -53,7 +92,7 @@ const de: SynapseTranslationMessages = {
|
|||||||
goToPdf: "Gehe zum PDF",
|
goToPdf: "Gehe zum PDF",
|
||||||
cards: {
|
cards: {
|
||||||
importstats: {
|
importstats: {
|
||||||
header: "Benutzer importieren",
|
header: "Geparste Benutzer für den Import",
|
||||||
users_total: "%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei",
|
users_total: "%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei",
|
||||||
guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste",
|
guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste",
|
||||||
admin_count: "%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren",
|
admin_count: "%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren",
|
||||||
@@ -116,6 +155,20 @@ const de: SynapseTranslationMessages = {
|
|||||||
send: "Diese API löscht die lokalen Medien von der Festplatte des eigenen Servers. Dies umfasst alle lokalen Miniaturbilder und Kopien von Medien. Diese API wirkt sich nicht auf Medien aus, die sich in externen Medien-Repositories befinden.",
|
send: "Diese API löscht die lokalen Medien von der Festplatte des eigenen Servers. Dies umfasst alle lokalen Miniaturbilder und Kopien von Medien. Diese API wirkt sich nicht auf Medien aus, die sich in externen Medien-Repositories befinden.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
purge_remote_media: {
|
||||||
|
name: "Externe Medien",
|
||||||
|
fields: {
|
||||||
|
before_ts: "letzter Zugriff vor",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
send: "Externe Medien löschen",
|
||||||
|
send_success: "Die Anfrage zum Löschen externer Medien wurde gesendet.",
|
||||||
|
send_failure: "Bei der Anfrage zum Löschen externer Medien ist ein Fehler aufgetreten.",
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
send: "Diese API löscht den externen Medien-Cache von der Festplatte Ihres eigenen Servers. Dazu gehören alle lokalen Thumbnails und Kopien heruntergeladener Medien. Diese API beeinflusst nicht die Medien, die in das eigene Medienarchiv des Servers hochgeladen wurden.",
|
||||||
|
},
|
||||||
|
},
|
||||||
resources: {
|
resources: {
|
||||||
users: {
|
users: {
|
||||||
name: "Benutzer",
|
name: "Benutzer",
|
||||||
@@ -129,10 +182,13 @@ const de: SynapseTranslationMessages = {
|
|||||||
is_guest: "Gast",
|
is_guest: "Gast",
|
||||||
admin: "Server Administrator",
|
admin: "Server Administrator",
|
||||||
locked: "Gesperrt",
|
locked: "Gesperrt",
|
||||||
|
suspended: "Suspendiert",
|
||||||
deactivated: "Deaktiviert",
|
deactivated: "Deaktiviert",
|
||||||
erased: "Gelöscht",
|
erased: "Gelöscht",
|
||||||
guests: "Zeige Gäste",
|
guests: "Zeige Gäste",
|
||||||
show_deactivated: "Zeige deaktivierte Benutzer",
|
show_deactivated: "Zeige deaktivierte Benutzer",
|
||||||
|
show_locked: "Zeige gesperrte Benutzer",
|
||||||
|
show_suspended: "Zeige suspendierte Benutzer",
|
||||||
user_id: "Suche Benutzer",
|
user_id: "Suche Benutzer",
|
||||||
displayname: "Anzeigename",
|
displayname: "Anzeigename",
|
||||||
password: "Passwort",
|
password: "Passwort",
|
||||||
@@ -148,11 +204,47 @@ const de: SynapseTranslationMessages = {
|
|||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
||||||
|
create_password: "Generiere ein starkes und sicheres Passwort mit dem Button unten.",
|
||||||
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
||||||
erase: "DSGVO konformes Löschen der Benutzerdaten",
|
suspend:
|
||||||
|
"Ein gesperrter Benutzer kann sich nicht mehr anmelden und wird in den schreibgeschützten Modus versetzt.",
|
||||||
|
erase: "DSGVO konformes Löschen der Benutzerdaten.",
|
||||||
|
erase_text:
|
||||||
|
"Das bedeutet, dass die von dem/den Benutzer(n) gesendeten Nachrichten für alle, die zum Zeitpunkt des Sendens im Raum waren, sichtbar bleiben, aber für Benutzer, die dem Raum später beitreten, nicht sichtbar sind.",
|
||||||
|
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt.",
|
||||||
|
modify_managed_user_error: "Das Ändern eines vom System verwalteten Benutzers ist nicht zulässig.",
|
||||||
|
username_available: "Benutzername verfügbar",
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
you: "Sie",
|
||||||
|
bot: "Bot",
|
||||||
|
admin: "Administrator",
|
||||||
|
support: "Unterstützung",
|
||||||
|
regular: "Normaler Benutzer",
|
||||||
|
system_managed: "Systemverwalteter Benutzer",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Lösche Benutzerdaten",
|
erase: "Lösche Benutzerdaten",
|
||||||
|
erase_avatar: "Avatar löschen",
|
||||||
|
delete_media: "Alle von dem/den Benutzer(n) hochgeladenen Medien löschen",
|
||||||
|
redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)",
|
||||||
|
generate_password: "Passwort generieren",
|
||||||
|
overwrite_title: "Warnung!",
|
||||||
|
overwrite_content:
|
||||||
|
"Dieser Benutzername ist bereits vergeben. Sind Sie sicher, dass Sie den vorhandenen Benutzer überschreiben möchten?",
|
||||||
|
overwrite_cancel: "Abbrechen",
|
||||||
|
overwrite_confirm: "Überschreiben",
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
messages_per_second: "Nachrichten pro Sekunde",
|
||||||
|
messages_per_second_text: "Die Anzahl der Aktionen, die in einer Sekunde durchgeführt werden können.",
|
||||||
|
burst_count: "Burst-Anzahl",
|
||||||
|
burst_count_text: "Die Anzahl der Aktionen, die vor der Begrenzung durchgeführt werden können.",
|
||||||
|
},
|
||||||
|
account_data: {
|
||||||
|
title: "Kontodaten",
|
||||||
|
global: "Globale",
|
||||||
|
rooms: "Räume",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -176,10 +268,11 @@ const de: SynapseTranslationMessages = {
|
|||||||
history_visibility: "Historie-Sichtbarkeit",
|
history_visibility: "Historie-Sichtbarkeit",
|
||||||
topic: "Thema",
|
topic: "Thema",
|
||||||
avatar: "Avatar",
|
avatar: "Avatar",
|
||||||
|
actions: "Aktionen",
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
forward_extremities:
|
forward_extremities:
|
||||||
"Forward extremities are the leaf events at the end of a Directed acyclic graph (DAG) in a room, aka events that have no children. The more exist in a room, the more state resolution that Synapse needs to perform (hint: it's an expensive operation). While Synapse has code to prevent too many of these existing at one time in a room, bugs can sometimes make them crop up again. If a room has >10 forward extremities, it's worth checking which room is the culprit and potentially removing them using the SQL queries mentioned in #1760.",
|
"Vorderextremitäten sind Blatt-Ereignisse am Ende eines gerichteten azyklischen Graphens (DAG) in einem Raum, auch bekannt als Ereignisse ohne Nachkommen. Je mehr in einem Raum existieren, umso mehr Zustandsauflösungen muss Synapse absolvieren (Hinweis: dies ist eine sehr aufwendige Operation). Obwohl Synapse Code hat um zu verhindern, dass zuviele davon gleichzeitig in einem Raum existieren, können Bugs manchmal dafür sorgen, dass sie sich ansammeln. Wenn ein Raum >10 Vorderextremitäten hat ist es sinnvoll zu überprüfen um welchen Raum es sich handelt und sie gegebenenfalls, wie in #1769 beschrieben, mittels SQL-Queries zu entfernen.",
|
||||||
},
|
},
|
||||||
enums: {
|
enums: {
|
||||||
join_rules: {
|
join_rules: {
|
||||||
@@ -204,7 +297,21 @@ const de: SynapseTranslationMessages = {
|
|||||||
erase: {
|
erase: {
|
||||||
title: "Raum löschen",
|
title: "Raum löschen",
|
||||||
content:
|
content:
|
||||||
"Sind Sie sicher dass Sie den Raum löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten und Medien, die der Raum beinhaltet werden vom Server gelöscht!",
|
"Sind Sie sicher, dass Sie den Raum löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten und Medien, die der Raum beinhaltet werden vom Server gelöscht!",
|
||||||
|
fields: {
|
||||||
|
block: "Blockieren und Benutzer daran hindern, dem Raum beizutreten",
|
||||||
|
},
|
||||||
|
success: "Raum/Räume erfolgreich gelöscht.",
|
||||||
|
failure: "Der/die Raum/Räume konnten nicht gelöscht werden.",
|
||||||
|
},
|
||||||
|
make_admin: {
|
||||||
|
assign_admin: "Raumadministrator zuweisen",
|
||||||
|
title: "Raumadministrator zu %{roomName} zuweisen",
|
||||||
|
confirm: "Raumadministrator zuweisen",
|
||||||
|
content:
|
||||||
|
"Geben Sie die vollständige MXID des Benutzers an, der als Administrator gesetzt werden soll.\nWarnung: Damit dies funktioniert, muss der Raum mindestens ein lokales Mitglied als Administrator haben.",
|
||||||
|
success: "Der/die Benutzer wurde/n als Raumadministrator gesetzt.",
|
||||||
|
failure: "Der/die Benutzer konnte/n nicht als Raumadministrator gesetzt werden. %{errMsg}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -239,7 +346,7 @@ const de: SynapseTranslationMessages = {
|
|||||||
erase: {
|
erase: {
|
||||||
title: "Gemeldetes Event löschen",
|
title: "Gemeldetes Event löschen",
|
||||||
content:
|
content:
|
||||||
"Sind Sie sicher dass Sie das gemeldete Event löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
"Sind Sie sicher, dass Sie das gemeldete Event löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -286,8 +393,8 @@ const de: SynapseTranslationMessages = {
|
|||||||
},
|
},
|
||||||
protect_media: {
|
protect_media: {
|
||||||
action: {
|
action: {
|
||||||
create: "Ungeschützt, Schutz erstellen",
|
create: "Ungeschützt, Schutz aktivieren",
|
||||||
delete: "Geschützt, Schutz aufheben",
|
delete: "Geschützt, Schutz deaktivieren",
|
||||||
none: "In Quarantäne",
|
none: "In Quarantäne",
|
||||||
send_success: "Erfolgreich den Schutz-Status geändert.",
|
send_success: "Erfolgreich den Schutz-Status geändert.",
|
||||||
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
|
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
|
||||||
@@ -357,6 +464,18 @@ const de: SynapseTranslationMessages = {
|
|||||||
sender: "Absender",
|
sender: "Absender",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
room_media: {
|
||||||
|
name: "Medien",
|
||||||
|
fields: {
|
||||||
|
media_id: "Medien ID",
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
info: "Dies ist eine Liste der Medien, die in den Raum hochgeladen wurden. Es ist nicht möglich, Medien zu löschen, die in externen Medien-Repositories hochgeladen wurden.",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
error: "%{errcode} (%{errstatus}) %{error}",
|
||||||
|
},
|
||||||
|
},
|
||||||
room_directory: {
|
room_directory: {
|
||||||
name: "Raumverzeichnis",
|
name: "Raumverzeichnis",
|
||||||
fields: {
|
fields: {
|
||||||
@@ -367,8 +486,8 @@ const de: SynapseTranslationMessages = {
|
|||||||
title: "Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen",
|
title: "Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen",
|
||||||
content:
|
content:
|
||||||
"Möchten Sie den Raum wirklich aus dem Raumverzeichnis löschen? |||| Möchten Sie die %{smart_count} Räume wirklich aus dem Raumverzeichnis löschen?",
|
"Möchten Sie den Raum wirklich aus dem Raumverzeichnis löschen? |||| Möchten Sie die %{smart_count} Räume wirklich aus dem Raumverzeichnis löschen?",
|
||||||
erase: "Lösche aus Verzeichnis",
|
erase: "Aus Verzeichnis löschen",
|
||||||
create: "Eintragen ins Verzeichnis",
|
create: "Ins Verzeichnis eintragen",
|
||||||
send_success: "Raum erfolgreich eingetragen.",
|
send_success: "Raum erfolgreich eingetragen.",
|
||||||
send_failure: "Beim Entfernen ist ein Fehler aufgetreten.",
|
send_failure: "Beim Entfernen ist ein Fehler aufgetreten.",
|
||||||
},
|
},
|
||||||
@@ -390,7 +509,7 @@ const de: SynapseTranslationMessages = {
|
|||||||
fields: {
|
fields: {
|
||||||
token: "Token",
|
token: "Token",
|
||||||
valid: "Gültige Token",
|
valid: "Gültige Token",
|
||||||
uses_allowed: "Anzahl",
|
uses_allowed: "Verwendungen erlaubt",
|
||||||
pending: "Ausstehend",
|
pending: "Ausstehend",
|
||||||
completed: "Abgeschlossen",
|
completed: "Abgeschlossen",
|
||||||
expiry_time: "Ablaufzeit",
|
expiry_time: "Ablaufzeit",
|
||||||
|
116
src/i18n/en.ts
@@ -7,17 +7,31 @@ const en: SynapseTranslationMessages = {
|
|||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "Homeserver URL",
|
base_url: "Homeserver URL",
|
||||||
welcome: "Welcome to Synapse-admin",
|
welcome: "Welcome to Synapse Admin",
|
||||||
server_version: "Synapse version",
|
server_version: "Synapse version",
|
||||||
supports_specs: "supports Matrix specs",
|
supports_specs: "supports Matrix specs",
|
||||||
username_error: "Please enter fully qualified user ID: '@user:domain'",
|
username_error: "Please enter fully qualified user ID: '@user:domain'",
|
||||||
protocol_error: "URL has to start with 'http://' or 'https://'",
|
protocol_error: "URL has to start with 'http://' or 'https://'",
|
||||||
url_error: "Not a valid Matrix server URL",
|
url_error: "Not a valid Matrix server URL",
|
||||||
sso_sign_in: "Sign in with SSO",
|
sso_sign_in: "Sign in with SSO",
|
||||||
|
credentials: "Credentials",
|
||||||
|
access_token: "Access token",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "You are using an existing Matrix access token.",
|
||||||
|
content:
|
||||||
|
"Do you want to destroy this session (that could be used elsewhere, e.g. in a Matrix client) or just logout from the admin panel?",
|
||||||
|
confirm: "Destroy session",
|
||||||
|
cancel: "Just logout from admin panel",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "Localpart of a Matrix user-id without homeserver.",
|
invalid_user_id: "Localpart of a Matrix user-id without homeserver.",
|
||||||
tabs: { sso: "SSO" },
|
tabs: {
|
||||||
|
sso: "SSO",
|
||||||
|
experimental: "Experimental",
|
||||||
|
limits: "Rate Limits",
|
||||||
|
account_data: "Account Data",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
details: "Room details",
|
details: "Room details",
|
||||||
@@ -26,6 +40,7 @@ const en: SynapseTranslationMessages = {
|
|||||||
members: "Members",
|
members: "Members",
|
||||||
detail: "Details",
|
detail: "Details",
|
||||||
permission: "Permissions",
|
permission: "Permissions",
|
||||||
|
media: "Media",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reports: { tabs: { basic: "Basic", detail: "Details" } },
|
reports: { tabs: { basic: "Basic", detail: "Details" } },
|
||||||
@@ -44,7 +59,7 @@ const en: SynapseTranslationMessages = {
|
|||||||
goToPdf: "Go to PDF",
|
goToPdf: "Go to PDF",
|
||||||
cards: {
|
cards: {
|
||||||
importstats: {
|
importstats: {
|
||||||
header: "Import users",
|
header: "Parsed users for import",
|
||||||
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
|
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
|
||||||
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
|
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
|
||||||
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
|
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
|
||||||
@@ -107,6 +122,20 @@ const en: SynapseTranslationMessages = {
|
|||||||
send: "This API deletes the local media from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to external media repositories.",
|
send: "This API deletes the local media from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to external media repositories.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
purge_remote_media: {
|
||||||
|
name: "Remote Media",
|
||||||
|
fields: {
|
||||||
|
before_ts: "last access before",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
send: "Purge remote media",
|
||||||
|
send_success: "Purge remote media request has been sent.",
|
||||||
|
send_failure: "An error has occurred with the purge remote media request.",
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
send: "This API purges the remote media cache from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to the server's own media repository.",
|
||||||
|
},
|
||||||
|
},
|
||||||
resources: {
|
resources: {
|
||||||
users: {
|
users: {
|
||||||
name: "User |||| Users",
|
name: "User |||| Users",
|
||||||
@@ -120,11 +149,13 @@ const en: SynapseTranslationMessages = {
|
|||||||
is_guest: "Guest",
|
is_guest: "Guest",
|
||||||
admin: "Server Administrator",
|
admin: "Server Administrator",
|
||||||
locked: "Locked",
|
locked: "Locked",
|
||||||
|
suspended: "Suspended",
|
||||||
deactivated: "Deactivated",
|
deactivated: "Deactivated",
|
||||||
erased: "Erased",
|
erased: "Erased",
|
||||||
guests: "Show guests",
|
guests: "Show guests",
|
||||||
show_deactivated: "Show deactivated users",
|
show_deactivated: "Show deactivated users",
|
||||||
show_locked: "Show locked users",
|
show_locked: "Show locked users",
|
||||||
|
show_suspended: "Show suspended users",
|
||||||
user_id: "Search user",
|
user_id: "Search user",
|
||||||
displayname: "Displayname",
|
displayname: "Displayname",
|
||||||
password: "Password",
|
password: "Password",
|
||||||
@@ -140,11 +171,45 @@ const en: SynapseTranslationMessages = {
|
|||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
password: "Changing password will log user out of all sessions.",
|
password: "Changing password will log user out of all sessions.",
|
||||||
|
create_password: "Generate a strong and secure password using the button below.",
|
||||||
deactivate: "You must provide a password to re-activate an account.",
|
deactivate: "You must provide a password to re-activate an account.",
|
||||||
|
suspend: "Suspending user means they are put into a read-only mode.",
|
||||||
erase: "Mark the user as GDPR-erased",
|
erase: "Mark the user as GDPR-erased",
|
||||||
|
erase_text:
|
||||||
|
"This means messages sent by the user(-s) will still be visible by anyone who was in the room when these messages were sent, but hidden from users joining the room afterward.",
|
||||||
|
erase_admin_error: "Deleting own user is not allowed.",
|
||||||
|
modify_managed_user_error: "Modifying a system-managed user is not allowed.",
|
||||||
|
username_available: "Username is available",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Erase user data",
|
erase: "Erase user data",
|
||||||
|
erase_avatar: "Erase avatar",
|
||||||
|
delete_media: "Delete all media uploaded by the user(-s)",
|
||||||
|
redact_events: "Redact all events sent by the user(-s)",
|
||||||
|
generate_password: "Generate password",
|
||||||
|
overwrite_title: "Warning!",
|
||||||
|
overwrite_content: "This username is already taken. Are you sure that you want to overwrite the existing user?",
|
||||||
|
overwrite_cancel: "Cancel",
|
||||||
|
overwrite_confirm: "Overwrite",
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
you: "You",
|
||||||
|
bot: "Bot",
|
||||||
|
admin: "Admin",
|
||||||
|
support: "Support",
|
||||||
|
regular: "Regular User",
|
||||||
|
system_managed: "System-managed",
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
messages_per_second: "Messages per second",
|
||||||
|
messages_per_second_text: "The number of actions that can be performed in a second.",
|
||||||
|
burst_count: "Burst count",
|
||||||
|
burst_count_text: "How many actions that can be performed before being limited.",
|
||||||
|
},
|
||||||
|
account_data: {
|
||||||
|
title: "Account Data",
|
||||||
|
global: "Global",
|
||||||
|
rooms: "Rooms",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -168,6 +233,7 @@ const en: SynapseTranslationMessages = {
|
|||||||
history_visibility: "History visibility",
|
history_visibility: "History visibility",
|
||||||
topic: "Topic",
|
topic: "Topic",
|
||||||
avatar: "Avatar",
|
avatar: "Avatar",
|
||||||
|
actions: "Actions",
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
forward_extremities:
|
forward_extremities:
|
||||||
@@ -197,6 +263,20 @@ const en: SynapseTranslationMessages = {
|
|||||||
title: "Delete room",
|
title: "Delete room",
|
||||||
content:
|
content:
|
||||||
"Are you sure you want to delete the room? This cannot be undone. All messages and shared media in the room will be deleted from the server!",
|
"Are you sure you want to delete the room? This cannot be undone. All messages and shared media in the room will be deleted from the server!",
|
||||||
|
fields: {
|
||||||
|
block: "Block and prevent users from joining the room",
|
||||||
|
},
|
||||||
|
success: "Room/s successfully deleted.",
|
||||||
|
failure: "The room/s could not be deleted.",
|
||||||
|
},
|
||||||
|
make_admin: {
|
||||||
|
assign_admin: "Assign admin",
|
||||||
|
title: "Assign a room admin to %{roomName}",
|
||||||
|
confirm: "Make admin",
|
||||||
|
content:
|
||||||
|
"Put the full MXID of the user which will be set as admin.\nWarning: for this to work, the room needs to have at least one local member as admin.",
|
||||||
|
success: "The user has been set as room admin.",
|
||||||
|
failure: "The user could not be set as room admin. %{errMsg}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -348,6 +428,18 @@ const en: SynapseTranslationMessages = {
|
|||||||
sender: "Sender",
|
sender: "Sender",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
room_media: {
|
||||||
|
name: "Media",
|
||||||
|
fields: {
|
||||||
|
media_id: "Media ID",
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
info: "This is a list of media that has been uploaded to the room. It is not possible to delete media that has been uploaded to external media repositories.",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
error: "%{errcode} (%{errstatus}) %{error}",
|
||||||
|
},
|
||||||
|
},
|
||||||
room_directory: {
|
room_directory: {
|
||||||
name: "Room directory",
|
name: "Room directory",
|
||||||
fields: {
|
fields: {
|
||||||
@@ -390,5 +482,23 @@ const en: SynapseTranslationMessages = {
|
|||||||
helper: { length: "Length of the token if no token is given." },
|
helper: { length: "Length of the token if no token is given." },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
scheduled_commands: {
|
||||||
|
action: {
|
||||||
|
create_success: "Scheduled command created successfully",
|
||||||
|
update_success: "Scheduled command updated successfully",
|
||||||
|
update_failure: "An error has occurred",
|
||||||
|
delete_success: "Scheduled command deleted successfully",
|
||||||
|
delete_failure: "An error has occurred",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recurring_commands: {
|
||||||
|
action: {
|
||||||
|
create_success: "Recurring command created successfully",
|
||||||
|
update_success: "Recurring command updated successfully",
|
||||||
|
update_failure: "An error has occurred",
|
||||||
|
delete_success: "Recurring command deleted successfully",
|
||||||
|
delete_failure: "An error has occurred",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
export default en;
|
export default en;
|
||||||
|
@@ -13,10 +13,19 @@ const fa: SynapseTranslationMessages = {
|
|||||||
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
|
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
|
||||||
url_error: "آدرس وارد شده یک سرور معتبر نیست",
|
url_error: "آدرس وارد شده یک سرور معتبر نیست",
|
||||||
sso_sign_in: "با SSO وارد شوید",
|
sso_sign_in: "با SSO وارد شوید",
|
||||||
|
credentials: "اعتبارنامه",
|
||||||
|
access_token: "توکن دسترسی",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "شما در حال استفاده از یک نشانه دسترسی ماتریکس موجود هستید.",
|
||||||
|
content:
|
||||||
|
"آیا میخواهید این جلسه (که میتواند در جای دیگر، مانند یک کلاینت ماتریکس استفاده شود) را نابود کنید یا فقط از پنل مدیریت خارج شوید؟",
|
||||||
|
confirm: "نابودی جلسه",
|
||||||
|
cancel: "فقط خروج از پنل مدیریت",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
|
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
|
||||||
tabs: { sso: "SSO" },
|
tabs: { sso: "SSO", experimental: "تجربی", limits: "محدودیت ها", account_data: "داده های کاربر" },
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
tabs: {
|
tabs: {
|
||||||
@@ -24,6 +33,7 @@ const fa: SynapseTranslationMessages = {
|
|||||||
members: "اعضا",
|
members: "اعضا",
|
||||||
detail: "جزئیات",
|
detail: "جزئیات",
|
||||||
permission: "مجوزها",
|
permission: "مجوزها",
|
||||||
|
media: "رسانه ها",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reports: { tabs: { basic: "اصلی", detail: "جزئیات" } },
|
reports: { tabs: { basic: "اصلی", detail: "جزئیات" } },
|
||||||
@@ -42,7 +52,7 @@ const fa: SynapseTranslationMessages = {
|
|||||||
goToPdf: "رفتن به PDF",
|
goToPdf: "رفتن به PDF",
|
||||||
cards: {
|
cards: {
|
||||||
importstats: {
|
importstats: {
|
||||||
header: "وارد کردن کاربران",
|
header: "کاربران پردازش شده برای وارد کردن",
|
||||||
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
|
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
|
||||||
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
|
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
|
||||||
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
|
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
|
||||||
@@ -105,6 +115,20 @@ const fa: SynapseTranslationMessages = {
|
|||||||
send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانههایی که در مخازن رسانه خارجی آپلود شدهاند تأثیری نخواهد گذاشت.",
|
send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانههایی که در مخازن رسانه خارجی آپلود شدهاند تأثیری نخواهد گذاشت.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
purge_remote_media: {
|
||||||
|
name: "رسانههای از راه دور",
|
||||||
|
fields: {
|
||||||
|
before_ts: "آخرین دسترسی قبل از",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
send: "پاک کردن رسانههای از راه دور",
|
||||||
|
send_success: "درخواست پاک کردن رسانههای از راه دور ارسال شد.",
|
||||||
|
send_failure: "درخواست برای پاک کردن رسانههای از راه دور با خطا مواجه شد.",
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
send: "این API کش رسانههای از راه دور را از دیسک سرور شما پاک میکند. این شامل هر گونه بندانگشتی محلی و نسخههای رسانههای دانلود شده میشود. این API بر رسانههای آپلود شده به مخزن رسانه سرور تأثیری نخواهد داشت.",
|
||||||
|
},
|
||||||
|
},
|
||||||
resources: {
|
resources: {
|
||||||
users: {
|
users: {
|
||||||
name: "کاربر |||| کاربران",
|
name: "کاربر |||| کاربران",
|
||||||
@@ -118,8 +142,12 @@ const fa: SynapseTranslationMessages = {
|
|||||||
is_guest: "مهمان",
|
is_guest: "مهمان",
|
||||||
admin: "مدیر سرور",
|
admin: "مدیر سرور",
|
||||||
deactivated: "غیرفعال",
|
deactivated: "غیرفعال",
|
||||||
|
locked: "قفل شده",
|
||||||
|
suspended: "معلق",
|
||||||
guests: "نمایش مهمانان",
|
guests: "نمایش مهمانان",
|
||||||
show_deactivated: "نمایش کاربران غیرفعال شده",
|
show_deactivated: "نمایش کاربران غیرفعال شده",
|
||||||
|
show_locked: "نمایش کاربران قفل شده",
|
||||||
|
show_suspended: "نمایش کاربران معلق",
|
||||||
user_id: "جستجوی کاربر",
|
user_id: "جستجوی کاربر",
|
||||||
displayname: "نام نمایشی",
|
displayname: "نام نمایشی",
|
||||||
password: "رمز عبور",
|
password: "رمز عبور",
|
||||||
@@ -135,11 +163,46 @@ const fa: SynapseTranslationMessages = {
|
|||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
|
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
|
||||||
|
create_password: "رمز عبور قوی و امنی را با استفاده از دکمه زیر ایجاد کنید.",
|
||||||
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
|
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
|
||||||
|
suspend: "کاربران معلق نمی توانند وارد شوند و پیام های آنها به دیگران نمایش داده نمی شود.",
|
||||||
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
|
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
|
||||||
|
erase_text:
|
||||||
|
"وهذا يعني أن الرسائل المرسلة من قبل المستخدم (المستخدمين) ستظل مرئية من قبل أي شخص كان في الغرفة عند إرسال هذه الرسائل، ولكنها مخفية عن المستخدمين الذين ينضمون إلى الغرفة بعد ذلك.",
|
||||||
|
erase_admin_error: "حذف المستخدم الخاص غير مسموح به.",
|
||||||
|
modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.",
|
||||||
|
username_available: "نام کاربری موجود",
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
you: "شما",
|
||||||
|
bot: "ربات",
|
||||||
|
admin: "مدیر",
|
||||||
|
support: "پشتیبان",
|
||||||
|
regular: "کاربر عادی",
|
||||||
|
system_managed: "مدیریت سیستم",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "پاک کردن اطلاعات کاربر",
|
erase: "پاک کردن اطلاعات کاربر",
|
||||||
|
erase_avatar: "محو الصورة الرمزية",
|
||||||
|
delete_media: "حذف جميع الوسائط التي تم تحميلها بواسطة المستخدم (المستخدمين)",
|
||||||
|
redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)",
|
||||||
|
generate_password: "توليد رمز عبور",
|
||||||
|
overwrite_title: "هشدار!",
|
||||||
|
overwrite_content:
|
||||||
|
"این نام کاربری قبلا استفاده شده است. آیا مطمئن هستید که می خواهید کاربر موجود را بازنویسی کنید؟",
|
||||||
|
overwrite_cancel: "انصراف",
|
||||||
|
overwrite_confirm: "بازنویسی",
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
messages_per_second: "پیام در ثانیه",
|
||||||
|
messages_per_second_text: "تعداد عملیاتی که می تواند در یک ثانیه انجام شود.",
|
||||||
|
burst_count: "تعداد پیچیدگی",
|
||||||
|
burst_count_text: "تعداد عملیاتی که می تواند قبل از محدودیت انجام شود.",
|
||||||
|
},
|
||||||
|
account_data: {
|
||||||
|
title: "داده های کاربر",
|
||||||
|
global: "عمومی",
|
||||||
|
rooms: "اتاق ها",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -163,6 +226,7 @@ const fa: SynapseTranslationMessages = {
|
|||||||
history_visibility: "مشاهده تاریخچه",
|
history_visibility: "مشاهده تاریخچه",
|
||||||
topic: "موضوع",
|
topic: "موضوع",
|
||||||
avatar: "آواتار",
|
avatar: "آواتار",
|
||||||
|
actions: "عملیات",
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
forward_extremities:
|
forward_extremities:
|
||||||
@@ -192,6 +256,20 @@ const fa: SynapseTranslationMessages = {
|
|||||||
title: "حذف اتاق",
|
title: "حذف اتاق",
|
||||||
content:
|
content:
|
||||||
"آیا مطمئن هستید که می خواهید اتاق را حذف کنید؟ این قابل بازگشت نیست. همه پیام ها و رسانه های مشترک در اتاق از سرور حذف می شوند!",
|
"آیا مطمئن هستید که می خواهید اتاق را حذف کنید؟ این قابل بازگشت نیست. همه پیام ها و رسانه های مشترک در اتاق از سرور حذف می شوند!",
|
||||||
|
fields: {
|
||||||
|
block: "حذف",
|
||||||
|
},
|
||||||
|
success: "اتاق با موفقیت حذف شد.",
|
||||||
|
failure: "خطایی رخ داده است.",
|
||||||
|
},
|
||||||
|
make_admin: {
|
||||||
|
assign_admin: "مدیر انتخاب کنید",
|
||||||
|
title: "مدیر اتاق %{roomName} را انتخاب کنید",
|
||||||
|
confirm: "مدیر انتخاب کنید",
|
||||||
|
content:
|
||||||
|
"کامل MXID کاربری را وارد کنید که به عنوان مدیر تنظیم شود.\nهشدار: برای این کار، اتاق باید حداقل یک اعضای محلی به عنوان مدیر داشته باشد.",
|
||||||
|
success: "کاربر به عنوان مدیر اتاق تنظیم شد.",
|
||||||
|
failure: "کاربر به عنوان مدیر اتاق تنظیم نشد. %{errMsg}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -330,6 +408,18 @@ const fa: SynapseTranslationMessages = {
|
|||||||
sender: "فرستنده",
|
sender: "فرستنده",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
room_media: {
|
||||||
|
name: "رسانه ها",
|
||||||
|
fields: {
|
||||||
|
media_id: "شناسه رسانه",
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
info: "این یک لیست از رسانه ها است که در اتاق بارگذاری شده است. نمی توان رسانه ها را حذف کرد که در اتاق های خارجی بارگذاری شده اند.",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
error: "%{errcode} (%{errstatus}) %{error}",
|
||||||
|
},
|
||||||
|
},
|
||||||
room_directory: {
|
room_directory: {
|
||||||
name: "راهنمای اتاق",
|
name: "راهنمای اتاق",
|
||||||
fields: {
|
fields: {
|
||||||
|
101
src/i18n/fr.ts
@@ -7,16 +7,30 @@ const fr: SynapseTranslationMessages = {
|
|||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "URL du serveur d’accueil",
|
base_url: "URL du serveur d’accueil",
|
||||||
welcome: "Bienvenue sur Synapse-admin",
|
welcome: "Bienvenue sur Synapse Admin",
|
||||||
server_version: "Version du serveur Synapse",
|
server_version: "Version du serveur Synapse",
|
||||||
username_error: "Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »",
|
username_error: "Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »",
|
||||||
protocol_error: "L'URL doit commencer par « http:// » ou « https:// »",
|
protocol_error: "L'URL doit commencer par « http:// » ou « https:// »",
|
||||||
url_error: "L'URL du serveur Matrix n'est pas valide",
|
url_error: "L'URL du serveur Matrix n'est pas valide",
|
||||||
sso_sign_in: "Se connecter avec l’authentification unique",
|
sso_sign_in: "Se connecter avec l’authentification unique",
|
||||||
|
credentials: "Identifiants",
|
||||||
|
access_token: "Jeton d'accès",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "Vous utilisez un jeton d'accès Matrix existant.",
|
||||||
|
content:
|
||||||
|
"Voulez-vous détruire cette session (qui pourrait être utilisée ailleurs, par exemple dans un client Matrix) ou simplement vous déconnecter du panneau d'administration?",
|
||||||
|
confirm: "Détruire la session",
|
||||||
|
cancel: "Se déconnecter simplement du panneau d'administration",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.",
|
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.",
|
||||||
tabs: { sso: "Authentification unique" },
|
tabs: {
|
||||||
|
sso: "Authentification unique",
|
||||||
|
experimental: "Expérimental",
|
||||||
|
limits: "Limites",
|
||||||
|
account_data: "Données du compte",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
tabs: {
|
tabs: {
|
||||||
@@ -24,6 +38,7 @@ const fr: SynapseTranslationMessages = {
|
|||||||
members: "Membres",
|
members: "Membres",
|
||||||
detail: "Détails",
|
detail: "Détails",
|
||||||
permission: "Permissions",
|
permission: "Permissions",
|
||||||
|
media: "Médias",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reports: { tabs: { basic: "Informations de base", detail: "Détails" } },
|
reports: { tabs: { basic: "Informations de base", detail: "Détails" } },
|
||||||
@@ -43,7 +58,7 @@ const fr: SynapseTranslationMessages = {
|
|||||||
goToPdf: "Voir le PDF",
|
goToPdf: "Voir le PDF",
|
||||||
cards: {
|
cards: {
|
||||||
importstats: {
|
importstats: {
|
||||||
header: "Importer des utilisateurs",
|
header: "Utilisateurs analysés pour l'import",
|
||||||
users_total:
|
users_total:
|
||||||
"%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV",
|
"%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV",
|
||||||
guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs",
|
guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs",
|
||||||
@@ -108,6 +123,20 @@ const fr: SynapseTranslationMessages = {
|
|||||||
send: "Cette API supprime les médias locaux du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléversés dans des dépôts de médias externes.",
|
send: "Cette API supprime les médias locaux du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléversés dans des dépôts de médias externes.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
purge_remote_media: {
|
||||||
|
name: "Médias distants",
|
||||||
|
fields: {
|
||||||
|
before_ts: "dernier accès avant",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
send: "Purger les médias distants",
|
||||||
|
send_success: "La demande de purge des médias distants a été envoyée.",
|
||||||
|
send_failure: "Une erreur est survenue lors de la demande de purge des médias distants.",
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
send: "Cette API purge le cache des médias distants du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléchargés dans le dépôt de médias du serveur.",
|
||||||
|
},
|
||||||
|
},
|
||||||
resources: {
|
resources: {
|
||||||
users: {
|
users: {
|
||||||
name: "Utilisateur |||| Utilisateurs",
|
name: "Utilisateur |||| Utilisateurs",
|
||||||
@@ -121,9 +150,12 @@ const fr: SynapseTranslationMessages = {
|
|||||||
is_guest: "Visiteur",
|
is_guest: "Visiteur",
|
||||||
admin: "Administrateur du serveur",
|
admin: "Administrateur du serveur",
|
||||||
locked: "Verrouillé",
|
locked: "Verrouillé",
|
||||||
|
suspended: "Suspendu",
|
||||||
deactivated: "Désactivé",
|
deactivated: "Désactivé",
|
||||||
guests: "Afficher les visiteurs",
|
guests: "Afficher les visiteurs",
|
||||||
show_deactivated: "Afficher les utilisateurs désactivés",
|
show_deactivated: "Afficher les utilisateurs désactivés",
|
||||||
|
show_locked: "Afficher les utilisateurs verrouillés",
|
||||||
|
show_suspended: "Afficher les utilisateurs suspendus",
|
||||||
user_id: "Rechercher un utilisateur",
|
user_id: "Rechercher un utilisateur",
|
||||||
displayname: "Nom d'affichage",
|
displayname: "Nom d'affichage",
|
||||||
password: "Mot de passe",
|
password: "Mot de passe",
|
||||||
@@ -137,11 +169,47 @@ const fr: SynapseTranslationMessages = {
|
|||||||
auth_provider: "Fournisseur d'identité",
|
auth_provider: "Fournisseur d'identité",
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
|
password: "Changer le mot de passe déconnectera l'utilisateur de toutes les sessions.",
|
||||||
|
create_password: "Générer un mot de passe fort et sécurisé en utilisant le bouton ci-dessous.",
|
||||||
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
||||||
|
suspend: "L'utilisateur sera suspendu jusqu'à ce que vous le réactiviez.",
|
||||||
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
||||||
|
erase_text:
|
||||||
|
"Cela signifie que les messages envoyés par le(s) utilisateur(s) seront toujours visibles par toute personne qui se trouvait dans la salle au moment où ces messages ont été envoyés, mais qu'ils seront cachés aux utilisateurs qui rejoindront la salle par la suite.",
|
||||||
|
erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.",
|
||||||
|
modify_managed_user_error: "La modification d'un utilisateur géré par le système n'est pas autorisée.",
|
||||||
|
username_available: "Nom d'utilisateur disponible",
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
you: "Vous",
|
||||||
|
bot: "Bot",
|
||||||
|
admin: "Admin",
|
||||||
|
support: "Support",
|
||||||
|
regular: "Utilisateur régulier",
|
||||||
|
system_managed: "Géré par le système",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Effacer les données de l'utilisateur",
|
erase: "Effacer les données de l'utilisateur",
|
||||||
|
erase_avatar: "Effacer l'avatar",
|
||||||
|
delete_media: "Supprimer tous les médias téléchargés par le(s) utilisateur(s)",
|
||||||
|
redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)",
|
||||||
|
generate_password: "Générer un mot de passe",
|
||||||
|
overwrite_title: "Attention !",
|
||||||
|
overwrite_content:
|
||||||
|
"Ce nom d'utilisateur est déjà pris. Êtes-vous sûr de vouloir écraser l'utilisateur existant ?",
|
||||||
|
overwrite_cancel: "Annuler",
|
||||||
|
overwrite_confirm: "Écraser",
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
messages_per_second: "Messages par seconde",
|
||||||
|
messages_per_second_text: "Le nombre d'actions que l'utilisateur peut effectuer par seconde.",
|
||||||
|
burst_count: "Compteur de pics",
|
||||||
|
burst_count_text: "Le nombre d'actions que l'utilisateur peut effectuer avant d'être limité.",
|
||||||
|
},
|
||||||
|
account_data: {
|
||||||
|
title: "Données du compte",
|
||||||
|
global: "Globales",
|
||||||
|
rooms: "Salons",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -165,6 +233,7 @@ const fr: SynapseTranslationMessages = {
|
|||||||
history_visibility: "Visibilité de l'historique",
|
history_visibility: "Visibilité de l'historique",
|
||||||
topic: "Sujet",
|
topic: "Sujet",
|
||||||
avatar: "Avatar",
|
avatar: "Avatar",
|
||||||
|
actions: "Actions",
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
forward_extremities:
|
forward_extremities:
|
||||||
@@ -194,6 +263,20 @@ const fr: SynapseTranslationMessages = {
|
|||||||
title: "Supprimer le salon",
|
title: "Supprimer le salon",
|
||||||
content:
|
content:
|
||||||
"Voulez-vous vraiment supprimer le salon ? Cette opération ne peut être annulée. Tous les messages et médias partagés du salon seront supprimés du serveur !",
|
"Voulez-vous vraiment supprimer le salon ? Cette opération ne peut être annulée. Tous les messages et médias partagés du salon seront supprimés du serveur !",
|
||||||
|
fields: {
|
||||||
|
block: "Bloquer et empêcher les utilisateurs de rejoindre la salle",
|
||||||
|
},
|
||||||
|
success: "Salle/s supprimées avec succès.",
|
||||||
|
failure: "La/les salle/s n'ont pas pu être supprimées.",
|
||||||
|
},
|
||||||
|
make_admin: {
|
||||||
|
assign_admin: "Assigner un administrateur",
|
||||||
|
title: "Assigner un administrateur au salon %{roomName}",
|
||||||
|
confirm: "Assigner un administrateur",
|
||||||
|
content:
|
||||||
|
"Entrez la MXID complète de l'utilisateur qui sera désigné comme administrateur.\nAttention : pour que cela fonctionne, la salle doit avoir au moins un membre local en tant qu'administrateur.",
|
||||||
|
success: "L'utilisateur a été désigné comme administrateur de la salle.",
|
||||||
|
failure: "L'utilisateur n'a pas pu être désigné comme administrateur de la salle. %{errMsg}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -332,6 +415,18 @@ const fr: SynapseTranslationMessages = {
|
|||||||
sender: "Expéditeur",
|
sender: "Expéditeur",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
room_media: {
|
||||||
|
name: "Médias",
|
||||||
|
fields: {
|
||||||
|
media_id: "Identifiant du média",
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
info: "Cette liste contient les médias qui ont été téléchargés dans le salon. Il n'est pas possible de supprimer les médias qui ont été téléversés dans des dépôts de médias externes.",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
error: "%{errcode} (%{errstatus}) %{error}",
|
||||||
|
},
|
||||||
|
},
|
||||||
room_directory: {
|
room_directory: {
|
||||||
name: "Répertoire des salons",
|
name: "Répertoire des salons",
|
||||||
fields: {
|
fields: {
|
||||||
|
106
src/i18n/index.d.ts
vendored
@@ -11,10 +11,18 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
protocol_error: string;
|
protocol_error: string;
|
||||||
url_error: string;
|
url_error: string;
|
||||||
sso_sign_in: string;
|
sso_sign_in: string;
|
||||||
|
credentials: string;
|
||||||
|
access_token: string;
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
confirm: string;
|
||||||
|
cancel: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: string;
|
invalid_user_id: string;
|
||||||
tabs: { sso: string };
|
tabs: { sso: string; experimental: string; limits: string; account_data: string };
|
||||||
};
|
};
|
||||||
rooms: {
|
rooms: {
|
||||||
details?: string; // TODO: fa, fr, it, zh
|
details?: string; // TODO: fa, fr, it, zh
|
||||||
@@ -23,6 +31,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
members: string;
|
members: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
permission: string;
|
permission: string;
|
||||||
|
media: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
reports: { tabs: { basic: string; detail: string } };
|
reports: { tabs: { basic: string; detail: string } };
|
||||||
@@ -103,6 +112,20 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
send: string;
|
send: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
purge_remote_media: {
|
||||||
|
name: string;
|
||||||
|
fields: {
|
||||||
|
before_ts: string;
|
||||||
|
};
|
||||||
|
action: {
|
||||||
|
send: string;
|
||||||
|
send_success: string;
|
||||||
|
send_failure: string;
|
||||||
|
};
|
||||||
|
helper: {
|
||||||
|
send: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
resources: {
|
resources: {
|
||||||
users: {
|
users: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -116,11 +139,13 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
is_guest: string;
|
is_guest: string;
|
||||||
admin: string;
|
admin: string;
|
||||||
locked?: string; // TODO: fa, zh
|
locked?: string; // TODO: fa, zh
|
||||||
|
suspended: string;
|
||||||
deactivated: string;
|
deactivated: string;
|
||||||
erased?: string; // TODO: fa, fr, it, zh
|
erased?: string; // TODO: fa, fr, it, zh
|
||||||
guests: string;
|
guests: string;
|
||||||
show_deactivated: string;
|
show_deactivated: string;
|
||||||
show_locked?: string; // TODO: de, fa, fr, it, zh
|
show_locked?: string; // TODO: de, fa, fr, it, zh
|
||||||
|
show_suspended: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
displayname: string;
|
displayname: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -135,12 +160,45 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
user_type?: string;
|
user_type?: string;
|
||||||
};
|
};
|
||||||
helper: {
|
helper: {
|
||||||
password?: string;
|
password: string;
|
||||||
|
create_password: string;
|
||||||
deactivate: string;
|
deactivate: string;
|
||||||
|
suspend: string;
|
||||||
erase: string;
|
erase: string;
|
||||||
|
erase_text: string;
|
||||||
|
erase_admin_error: string;
|
||||||
|
modify_managed_user_error: string;
|
||||||
|
username_available: string;
|
||||||
};
|
};
|
||||||
action: {
|
action: {
|
||||||
erase: string;
|
erase: string;
|
||||||
|
erase_avatar: string;
|
||||||
|
delete_media: string;
|
||||||
|
redact_events: string;
|
||||||
|
generate_password: string;
|
||||||
|
overwrite_title: string;
|
||||||
|
overwrite_content: string;
|
||||||
|
overwrite_cancel: string;
|
||||||
|
overwrite_confirm: string;
|
||||||
|
};
|
||||||
|
badge: {
|
||||||
|
you: string;
|
||||||
|
bot: string;
|
||||||
|
admin: string;
|
||||||
|
support: string;
|
||||||
|
regular: string;
|
||||||
|
system_managed: string;
|
||||||
|
};
|
||||||
|
limits: {
|
||||||
|
messages_per_second: string;
|
||||||
|
messages_per_second_text: string;
|
||||||
|
burst_count: string;
|
||||||
|
burst_count_text: string;
|
||||||
|
};
|
||||||
|
account_data: {
|
||||||
|
title: string;
|
||||||
|
global: string;
|
||||||
|
rooms: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -164,6 +222,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
history_visibility: string;
|
history_visibility: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
actions: string;
|
||||||
};
|
};
|
||||||
helper?: {
|
helper?: {
|
||||||
forward_extremities: string;
|
forward_extremities: string;
|
||||||
@@ -191,6 +250,19 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
erase: {
|
erase: {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
fields: {
|
||||||
|
block: string;
|
||||||
|
};
|
||||||
|
success: string;
|
||||||
|
failure: string;
|
||||||
|
};
|
||||||
|
make_admin: {
|
||||||
|
assign_admin: string;
|
||||||
|
title: string;
|
||||||
|
confirm: string;
|
||||||
|
content: string;
|
||||||
|
success: string;
|
||||||
|
failure: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -344,6 +416,18 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
sender: string;
|
sender: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
room_media?: {
|
||||||
|
name: string;
|
||||||
|
fields: {
|
||||||
|
media_id: string;
|
||||||
|
};
|
||||||
|
helper: {
|
||||||
|
info: string;
|
||||||
|
};
|
||||||
|
action: {
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
room_directory?: {
|
room_directory?: {
|
||||||
name: string;
|
name: string;
|
||||||
fields: {
|
fields: {
|
||||||
@@ -389,4 +473,22 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
scheduled_commands?: {
|
||||||
|
action: {
|
||||||
|
create_success: string;
|
||||||
|
update_success: string;
|
||||||
|
update_failure: string;
|
||||||
|
delete_success: string;
|
||||||
|
delete_failure: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
recurring_commands?: {
|
||||||
|
action: {
|
||||||
|
create_success: string;
|
||||||
|
update_success: string;
|
||||||
|
update_failure: string;
|
||||||
|
delete_success: string;
|
||||||
|
delete_failure: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|